Using fetchpriority and preload for LCP Images

Your Largest Contentful Paint element is an image that the browser fetches hundreds of milliseconds too late β€” it sits at the back of the resource queue at Low priority while stylesheets and scripts drain the connection pool first, a scheduling decision made during the resource-loading phase before layout ever runs.

This guide is part of Preload Scanner and Resource Loading, itself a topic under Browser Rendering Pipeline Fundamentals. Where the companion guide on how the preload scanner speculatively loads resources explains when a resource is discovered, this one is about what priority it gets once discovered β€” and how fetchpriority and rel="preload" let you override the browser’s default guess for the one image that decides your LCP score.

Reproducing the Late Fetch

The problem is not that the image is undiscoverable β€” the preload scanner finds it fine. The problem is the priority the network stack assigns it. Here is a hero layout that reliably ships its LCP image late:

<!DOCTYPE html>
<html>
<head>
  <link rel="stylesheet" href="/css/app.css">
  <script src="/js/app.js" defer></script>
</head>
<body>
  <header class="hero">
    <img src="/img/hero-4k.avif" alt="Product hero">  <!-- BAD LINE: no fetchpriority, defaults to Low until layout -->
  </header>
  <p>… 40 KB of copy the parser tokenizes before layout runs …</p>
</body>
</html>

Chromium assigns every in-<body> <img> an initial priority of Low. It only upgrades an image to High after layout determines the element is inside the initial viewport β€” and layout cannot run until the CSSOM is built and enough DOM exists to lay out the hero. So the single most important pixel-bearing resource on the page waits behind app.css, app.js, and any earlier-queued fetch, then gets bumped up mid-flight. On a Slow 4G connection with a bounded connection pool, that late upgrade is worth 300–600 ms of LCP.

How the Resource Loader Prioritises

Chromium’s ResourceFetcher maps every request onto one of five priority buckets β€” VeryHigh, High, Medium, Low, VeryLow β€” and hands them to the network service, which multiplexes them over HTTP/2 or drains them through a small pool of HTTP/1.1 sockets. The scheduler is a priority queue: a Low request is not dispatched while a High request is still pending on a saturated connection. The default heuristic that hurts you is purely structural β€” it keys off tag type and layout position, not importance:

Resource Initial priority Upgrade trigger
CSS in <head> VeryHigh none needed
Sync script High none needed
<img> (in viewport) Low after layout marks it visible
<img> (below fold) Low stays Low
fetch() High none needed

The image’s real weight is invisible to the loader at discovery time because β€œis this in the viewport” is a layout fact, and layout runs after the fetch queue has already been ordered. fetchpriority="high" closes that gap: it is a hint the parser attaches to the request the instant the token is seen, so the ResourceFetcher files the image under High immediately β€” no waiting for layout to prove the element matters.

Resource priority queue with and without fetchpriority Without the hint the hero image sits at Low behind CSS and script; with the hint it is promoted to High and dispatched first. Default queue With fetchpriority="high" app.css β€” VeryHigh app.js β€” High hero-4k.avif β€” Low dispatched last, upgraded mid-flight hero-4k.avif β€” High app.css β€” VeryHigh app.js β€” High dispatched in the first flight

Note the nuance: CSS still outranks the image because CSS is VeryHigh and fetchpriority="high" only reaches High. That is correct β€” you want the CSSOM built too. What you have bought is a place in the first flight of requests instead of the second, and an escape from the layout-gated upgrade.

Reading the Trace

Load the page with the Network panel open, enable the Priority column, and record a Performance trace. The signature of the bug is an image request whose priority starts Low and whose start time trails the document’s other subresources. Annotated, the before trace looks like this:

Navigation start ─┬─ 0 ms
                  β”‚
  main thread ───── [Parse HTML]───[token: <img hero>]  ← discovered, filed as Low
                  β”‚
  net (socket 1) ── [app.css   VeryHigh] β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
  net (socket 2) ── [app.js    High    ] β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
                  β”‚                          ↓ sockets busy
  net (queued)  ─── [hero.avif Low     ] Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β· waiting
                  β”‚                                     β”Œ layout runs
  main thread ───── [Style]──[Layout]β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ upgrades img β†’ High
  net (socket 1) ──                        [hero.avif High] β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
                  β”‚                                              ↓
  LCP ────────────┴──────────────────────────────────────────── 1180 ms  βœ—

The hero.avif bar does not even begin until a socket frees up and layout has run to justify the upgrade. Two serialised waits β€” connection contention and the layout gate β€” stack in front of the byte transfer. The fix collapses both.

Timeline of the late fetch versus the promoted fetch The default path serialises discovery, queue wait, and a layout-gated upgrade; the promoted path fetches the image in parallel from the start. Before parse + CSS img queued (Low) layout gate img fetch (High) LCP 1180 ms After parse + CSS img fetch (High) β€” parallel LCP 720 ms

The Fix: preload plus fetchpriority

Two mechanisms combine. fetchpriority="high" on the <img> corrects the priority the moment the token is parsed. A <link rel="preload" as="image" fetchpriority="high"> in the <head> corrects the discovery time β€” the preload scanner sees it before it reaches the body, and for responsive images the imagesrcset attribute lets the scanner pick the right candidate without waiting for layout to compute sizes. Use both when the image is deep in the body or selected by srcset; use fetchpriority alone when the <img> is already near the top of the document.

Before:

<head>
  <link rel="stylesheet" href="/css/app.css">
</head>
<body>
  <header class="hero">
    <!-- Low priority, discovered late, upgraded only after layout -->
    <img src="/img/hero-4k.avif"
         srcset="/img/hero-1k.avif 1024w, /img/hero-4k.avif 3840w"
         sizes="100vw" alt="Product hero">
  </header>
</body>

After:

<head>
  <link rel="stylesheet" href="/css/app.css">
  <!-- preload scanner queues the right candidate at High before body is parsed -->
  <link rel="preload" as="image"
        href="/img/hero-4k.avif"
        imagesrcset="/img/hero-1k.avif 1024w, /img/hero-4k.avif 3840w"
        imagesizes="100vw"
        fetchpriority="high">
</head>
<body>
  <header class="hero">
    <!-- fetchpriority pins the request to High the instant the token is seen -->
    <img src="/img/hero-4k.avif"
         srcset="/img/hero-1k.avif 1024w, /img/hero-4k.avif 3840w"
         sizes="100vw" fetchpriority="high"
         alt="Product hero">
  </header>
</body>

Keep the <img> markup identical between the preload and the element β€” same imagesrcset/srcset and imagesizes/sizes β€” or the loader treats them as two different resources and double-fetches. The preload and the element must resolve to the same candidate URL for the connection to be reused.

Decision tree for choosing preload versus fetchpriority A branch showing when to use fetchpriority alone, when to add a preload link, and the pitfall of mismatched candidates. Is the LCP image the first big in-viewport img? near top deep / srcset fetchpriority="high" on the img alone preload + fetchpriority, matched imagesrcset mismatch = double fetch

One more rule that engineers miss: fetchpriority="high" is a hint, not a mandate, and it only helps the resource that is your LCP element. Marking three carousel slides high splits your first-flight bandwidth three ways and slows the one image that counts. Promote exactly one. And never set loading="lazy" on the LCP image β€” lazy loading defers the request out of the initial flight entirely, which directly contradicts the promotion and is a common cause of a β€œpreload was not used” console warning.

Verification Checklist

Frequently Asked Questions

Do I need both preload and fetchpriority, or is one enough?

It depends on discovery. If the <img> is near the top of the document and selected by a simple src, fetchpriority="high" on the element alone is enough β€” the preload scanner already finds it early. Add a <link rel="preload"> when the image is deep in the body, injected by JavaScript, or chosen from a srcset/sizes combination the scanner cannot resolve without layout. In that case the preload fixes discovery time and fetchpriority fixes priority.

Why does my LCP image default to Low priority at all?

Chromium assigns in-body images Low priority at discovery because it cannot yet know they are in the viewport β€” that is a layout fact, and layout runs after the fetch queue is ordered. The browser upgrades the image to High only once layout confirms it is visible, which is exactly the late upgrade fetchpriority="high" lets you skip. See how the preload scanner speculatively loads resources for the discovery half of this.

Can I preload the LCP image to make it outrank CSS?

No, and you should not want to. A fetchpriority="high" image lands in the High bucket, while render-blocking CSS sits in VeryHigh, so the stylesheet still wins. That is correct: you need the CSSOM built to run style calculation and cascade and paint the image once it arrives. The win is getting the image into the first flight of requests, not ahead of the critical CSS.

Why do I see the image fetched twice after adding a preload?

The preload and the <img> resolve to different candidate URLs. If the preload uses imagesrcset/imagesizes that do not exactly match the element’s srcset/sizes, the loader picks a different resolution and issues a second request. Make the two declarations byte-for-byte identical so the cached preload response is reused.

Does fetchpriority work for background-image LCP elements?

Not directly β€” a CSS background-image is discovered only when the CSSOM is parsed and the element matches, which is inherently late. For a background LCP element, add a <link rel="preload" as="image" fetchpriority="high"> in the <head> so the scanner fetches it before the CSS resolves the rule. The fetchpriority attribute on an <img> element has no equivalent on a CSS declaration.