Optimizing LCP with fetchpriority & preload

Your hero image is the Largest Contentful Paint element, but the browser discovers it late and assigns it Low fetch priority because it is an in-viewport <img> the preload scanner reaches only after the markup before it streams in. The fix is not a CDN or a bigger server — it is telling the browser, explicitly and as early as possible, that this one asset is the most important byte on the page. This page is the concrete recipe: raise the hero to fetchpriority="high", surface it to the preload scanner with <link rel="preload" as="image"> carrying imagesrcset/imagesizes, preconnect to its origin, never lazy-load it, and demote everything below the fold so it stops competing. It sits under LCP Measurement & Optimization, the reference that covers candidate selection and thresholds; the late-discovery problem itself is a resource-timing issue rooted in FCP and TTFB analysis, and you confirm the win by measuring the before/after delta with the web-vitals API implementation patterns.

LCP scores against the current Google spec: ≤ 2.5 s is Good, ≤ 4.0 s is Needs Improvement, and > 4.0 s is Poor, aggregated at p75 across your real-user population. Priority hints do not change how fast your bytes travel; they change when the request starts and how much bandwidth it gets relative to its rivals. On a load where the hero starts 600 ms late behind fonts, analytics, and a render-blocking stylesheet, recovering that head start is often the single largest p75 LCP improvement available.

Hero discovery timeline: before vs after Without a preload the hero is discovered late and fetched at low priority; with preload and fetchpriority high it starts at connection open and finishes well before the LCP threshold. Before: hero discovered late, Low priority HTML + CSS fonts + JS hero fetch (Low) LCP > 4.0 s After: preconnect + preload + fetchpriority high preconnect hero fetch (High) LCP < 2.5 s The hero starts at connection open instead of after every other resource Recovered head start = the p75 LCP delta you measure earlier start + more bandwidth, same bytes
Priority hints move the hero request earlier and give it bandwidth; the recovered head start is the LCP improvement you confirm against your field measurement pipeline.

Prerequisites

  • A clearly identified LCP element. Record a load in the Chrome DevTools Performance panel and read the element off the LCP marker in the Timings track. These techniques apply only to the one element that wins LCP — usually a hero <img>, a CSS background-image, or a <video> poster.
  • The hero served from a known origin. If it is on a separate image CDN (cdn.example.com), you need that exact origin for preconnect.
  • A field measurement hookup so you can quantify the change. The before/after comparison below assumes you already capture per-load LCP, as built in Measure LCP with the PerformanceObserver API or via the web-vitals library.
  • Control over the document <head> markup. Several steps require injecting <link> tags before the <body> streams, which a templating layer or framework <Head> component must allow.
  • Chrome/Edge 102+, or Firefox/Safari 17+ for fetchpriority support. Older engines ignore the attribute harmlessly, so it is safe to ship unconditionally.

How to make the LCP element load first

1. Set fetchpriority=“high” on the hero image

The browser assigns in-viewport <img> elements an initial priority of Low and only bumps them once layout proves they are visible — too late. fetchpriority="high" skips that delay.

<img
  src="/hero-1280.jpg"
  srcset="/hero-640.jpg 640w, /hero-1280.jpg 1280w, /hero-1920.jpg 1920w"
  sizes="(max-width: 768px) 100vw, 1200px"
  fetchpriority="high"
  decoding="async"
  width="1200"
  height="630"
  alt="Product dashboard overview">

Why: With fetchpriority="high" the resource is queued at High from the first byte of HTML the preload scanner sees, so it is not starved by the dozen Low-priority requests around it. The explicit width/height reserve layout space and prevent the layout shift that would otherwise undercut your CLS reduction work, and decoding="async" keeps image decode off the critical path to first paint.

2. Preload the responsive hero before the scanner reaches it

The attribute in step 1 helps once the <img> is parsed, but if it sits below a chunk of streamed markup it is still discovered late. A <link rel="preload"> in the <head> starts the fetch immediately — and imagesrcset/imagesizes make that preload responsive so you do not download the wrong candidate.

<head>
  <!-- Must come BEFORE any render-blocking CSS that delays the scanner. -->
  <link
    rel="preload"
    as="image"
    href="/hero-1280.jpg"
    imagesrcset="/hero-640.jpg 640w, /hero-1280.jpg 1280w, /hero-1920.jpg 1920w"
    imagesizes="(max-width: 768px) 100vw, 1200px"
    fetchpriority="high">
</head>

Why: The imagesrcset and imagesizes attributes mirror the <img> srcset/sizes exactly so the preload and the element resolve to the same URL — otherwise you fetch one candidate via preload and a different one via the <img>, doubling bytes and slowing LCP. Putting the preload first in the <head> removes the late-discovery gap entirely: the request opens before the parser even reaches the hero markup.

3. Preconnect to the image origin

If the hero lives on a different origin, the request still pays DNS, TCP, and TLS before the first byte. preconnect warms that connection in parallel with HTML parsing.

<head>
  <link rel="preconnect" href="https://cdn.example.com" crossorigin>
  <link rel="dns-prefetch" href="https://cdn.example.com">
  <link
    rel="preload"
    as="image"
    href="https://cdn.example.com/hero-1280.jpg"
    imagesrcset="https://cdn.example.com/hero-640.jpg 640w, https://cdn.example.com/hero-1280.jpg 1280w"
    imagesizes="(max-width: 768px) 100vw, 1200px"
    fetchpriority="high">
</head>

Why: A cold cross-origin connection on a 4G RTT can cost 150–300 ms before any image bytes flow. preconnect collapses that into the parse window. The crossorigin attribute is required when the asset is fetched anonymously so the warmed connection actually matches the later request; the dns-prefetch is a cheap fallback for engines that cap concurrent preconnects.

4. Never lazy-load the hero

Lazy-loading is the correct default for below-the-fold images, but applying it to the LCP element is the most common self-inflicted LCP regression. Keep the hero eager and explicit.

<!-- WRONG: hero deferred until layout/scroll heuristics decide it is needed. -->
<img src="/hero-1280.jpg" loading="lazy" fetchpriority="high" alt="">

<!-- RIGHT: eager, high priority, async decode. -->
<img src="/hero-1280.jpg" loading="eager" fetchpriority="high" decoding="async" alt="">

Why: loading="lazy" forces the browser to run layout before it will even start the fetch, which directly contradicts the fetchpriority="high" hint and pushes the LCP paint hundreds of milliseconds later. Many component libraries set loading="lazy" globally; explicitly set loading="eager" on the hero to opt it out. This is a late-discovery problem in the same family discussed in TTFB vs FCP analysis.

5. Demote below-the-fold and non-critical requests

Raising one resource only helps if you also stop its rivals from hogging the connection. Mark carousel slides, footer images, and non-critical scripts as Low priority so the hero wins contention.

<!-- Off-screen carousel slides: keep them out of the hero's way. -->
<img src="/slide-2.jpg" loading="lazy" fetchpriority="low" decoding="async" alt="">
<img src="/slide-3.jpg" loading="lazy" fetchpriority="low" decoding="async" alt="">
// Demote a non-critical data prefetch that would otherwise contend for bandwidth.
fetch('/api/recommendations', { priority: 'low' })
  .then((r) => r.json())
  .then(renderRecommendations)
  .catch(() => {});

Why: Priority is relative — bumping the hero to High while leaving ten other High/Auto requests in flight on a constrained connection wins little. Demoting the genuinely deferrable work to Low (via fetchpriority="low" on elements and the priority: 'low' Fetch option on scripts) frees bandwidth for the hero so its bytes arrive sooner.

6. Measure the before/after LCP delta

Ship the change behind a flag or to a canary, then compare p75 LCP before and after. Tag each beacon with the variant so your backend can split the distribution.

import { onLCP } from 'web-vitals';

// Read the rollout variant injected by your edge/server.
const variant = document.documentElement.dataset.heroPreload || 'control';

onLCP((metric) => {
  const body = JSON.stringify({
    metric: 'LCP',
    value: Math.round(metric.value),
    rating: metric.rating,        // 'good' | 'needs-improvement' | 'poor'
    variant,                      // 'control' vs 'preload'
    element: metric.entries.at(-1)?.element?.tagName || 'unknown',
    page: location.pathname,
    ts: Date.now()
  });
  navigator.sendBeacon('/api/v1/rum/ingest', body);
});
-- Compare p75 LCP per variant over the experiment window.
SELECT
  variant,
  count(*)                                            AS samples,
  quantileExact(0.75)(value)                          AS p75_lcp_ms,
  countIf(rating = 'good') / count(*)                 AS good_rate
FROM rum_events
WHERE metric = 'LCP'
  AND page = '/'
  AND ts >= now() - INTERVAL 7 DAY
GROUP BY variant
ORDER BY variant;

Why: A single fast load in DevTools proves the mechanism but not the population win — only p75 over real users does that, and only a variant split rules out an unrelated week-over-week shift. The onLCP lifecycle handling (buffered capture, finalize on hidden) is the same machinery documented in the web-vitals API implementation patterns; tagging the beacon with variant is what makes the delta attributable to your change rather than noise.

Verifying it works

  1. DevTools Network Priority column. Right-click the Network panel header, enable Priority, and reload. The hero request should show High and start within the first few requests, not buried after fonts and scripts. Before the change it typically shows Low.
  2. No duplicate hero download. Confirm the preload and the <img> resolve to one URL: the hero should appear exactly once in the Network panel. Two entries (e.g. hero-1280.jpg and hero-1920.jpg) mean your imagesizes and sizes disagree — Chrome also logs a was preloaded using link preload but not used console warning.
  3. Performance panel LCP marker. Record a trace; the LCP timestamp in the Timings track should move earlier by roughly the recovered discovery + connection time. Hover the marker to confirm it still points at the same hero element.
  4. RUM dashboard signal. After the canary collects traffic, your p75 LCP per variant should diverge in the SQL above. A healthy result is the preload variant showing a lower p75_lcp_ms and a higher good_rate. If the two variants are statistically identical, the hero was probably not the bottleneck — re-confirm the LCP element in step 1 of the prerequisites.

Edge cases & gotchas

  • Preloading the wrong candidate wastes bytes. If imagesrcset/imagesizes do not byte-for-byte match the <img> srcset/sizes, the browser preloads one resolution and renders another, doubling download and worsening LCP. Keep the two in lockstep, ideally generated from one template variable.
  • Over-preloading starves the hero. Preloading three fonts, a stylesheet, and the hero all at High defeats the purpose — they share the connection. Preload only the genuine LCP asset at High; let the rest stay Auto or Low.
  • CSS background-image heroes cannot use <img> attributes. When the LCP element is a CSS background, the fetchpriority attribute does not apply. Use a <link rel="preload" as="image"> for the background URL, or refactor the hero into a real <img> so it can carry the hint and be discovered by the preload scanner.
  • fetchpriority does not raise an explicitly lazy image. If loading="lazy" is present, the lazy heuristic still gates the fetch behind layout regardless of the priority hint. The two attributes conflict; remove lazy from the hero (step 4).
  • Responsive sizes mismatch on mobile. A sizes value tuned for desktop can make mobile download the 1920w variant. Verify the resolved candidate on a throttled mobile profile; an oversized hero is slower even when it starts first.
  • Preload without a matching element is a warning, not a win. If the preloaded URL is never used (a stale path, a media query that excludes it), Chrome warns and you have spent bandwidth for nothing. The “no duplicate download” check above catches this.

FAQ

Should I use fetchpriority on the img, the preload link, or both?

Both, and keep them consistent. The <link rel="preload" fetchpriority="high"> starts the fetch early at High priority; the matching <img fetchpriority="high"> ensures that when the parser reaches the element it does not re-queue the request at a lower priority. Using only one leaves a gap — preload alone can still be deprioritized at element time, and the attribute alone still suffers late discovery.

Does preloading the hero help if my server is slow?

Only partially. Preload and fetchpriority recover discovery and connection time and win bandwidth contention, but they cannot speed up bytes that travel slowly. If your TTFB is the bottleneck, address that first — see TTFB vs FCP: What Really Matters for SEO — then layer these hints on top.

Why did my LCP not improve after adding the preload?

Most often the preloaded element is not actually the LCP element, or the hero was never the bottleneck on your real-user connections. Re-identify the LCP element in DevTools, confirm the Network Priority shows High and an early start, and check the per-variant p75 split — if it is flat, the LCP-determining work is elsewhere (render-blocking CSS, hydration, or server time).