When to Use Preload vs Prefetch for Images

Hero images behind CSS background-image or lazy-loaded components arrive too late to win the Largest Contentful Paint race — preload fixes this; prefetch makes it worse.

Why the Browser Gets Image Priority Wrong

The HTML parser discovers <img> tags early and assigns them a network priority based on viewport position. An image in the top fold gets High; one below the fold gets Low. The trouble starts when a hero image is not in the HTML at all — it lives in a CSS background-image rule, a JS component, or a <picture> element rendered after a framework hydration cycle. In those cases the browser’s fetch-priority queue never sees the image during the preload scan. The request is deferred until CSS is parsed and the render tree is partially built, arriving hundreds of milliseconds too late.

link rel="preload" bypasses this by injecting a mandatory, high-priority fetch directly into the browser’s preload scanner — the lightweight speculative pass that runs before the DOM is constructed. The browser begins the network request before it has even started building the render tree. link rel="prefetch", by contrast, runs at Low or Idle network priority after all critical resources have been served. It is designed for resources the user will probably need on the next page, not the current one.

The distinction matters because the two hints use different slots in the network waterfall. A preloaded image appears in the waterfall at or before DOMContentLoaded; a prefetched image appears after it, during idle time. Applying prefetch to an LCP candidate shifts its fetch into the wrong waterfall slot and actively delays paint.

Network waterfall comparing preload and prefetch timing for the same hero image A three-row waterfall on a nought to six hundred millisecond axis. Critical CSS and JavaScript occupy nought to two hundred milliseconds. The same hero image, when preloaded, runs from ten to two hundred and ninety milliseconds and finishes well before DOMContentLoaded at four hundred milliseconds. When the identical image is hinted with prefetch instead, its fetch does not begin until four hundred and twenty milliseconds, long after the paint deadline has passed. 0 ms 100 ms 200 ms 300 ms 400 ms 500 ms 600 ms DCL 400 ms CSS / JS critical CSS + JS Preload hero image, 10 to 290 ms Prefetch same image, 420 to 600 ms Preload: mandatory, High band Prefetch: opportunistic, Idle band

Minimal Reproduction

The smallest snippet that demonstrates the preload fix for a CSS background image — the most common missed case:

<head>
  <meta name="viewport" content="width=device-width,initial-scale=1">

  <!-- Inject before any stylesheet so the preload scanner wins the race.
       as="image" tells the browser the MIME category; fetchpriority="high"
       promotes this above other High-tier requests (fonts, scripts). -->
  <link rel="preload" as="image" href="/hero.webp" fetchpriority="high">

  <link rel="stylesheet" href="/styles.css">
</head>
<body>
  <!-- The CSS rule below would normally delay discovery until CSSOM is built.
       The preload above has already started the fetch by then. -->
  <div class="hero"></div>
</body>
/* styles.css — without the preload above, this triggers a late fetch */
.hero {
  background-image: url('/hero.webp');
  width: 100%;
  height: 480px;
}

For a responsive <img> with srcset, use imagesrcset and imagesizes on the <link> element so the browser preloads the exact variant it will use after layout:

<!-- imagesrcset mirrors the img srcset; imagesizes mirrors the img sizes.
     Without these attributes, the browser preloads the default src and then
     fetches the correct srcset variant anyway — a double fetch. -->
<link
  rel="preload"
  as="image"
  imagesrcset="hero-400w.webp 400w, hero-800w.webp 800w, hero-1200w.webp 1200w"
  imagesizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px"
  fetchpriority="high"
>

Choosing the Hint: One Decision Path

Two questions settle every image on a page, and they have to be asked in that order. The first is whether the image is the Largest Contentful Paint candidate — the largest image intersecting the viewport at first paint, which is the element the browser actually measures. The second applies only when the answer to the first was yes: can the preload scanner see it? Sort the Network panel by the Initiator column and read the row for that image. parser means the scanner already found the URL in the raw HTML byte stream and the fetch was dispatched before the CSSOM existed — a preload hint adds nothing but a second cache key to get wrong. script, css, or other means discovery is gated behind something slower, and a <link rel="preload"> in <head> is the only thing that moves the fetch earlier.

If the image is not the LCP candidate, the branch stops being about priority and becomes about navigation scope. An image needed on this page but below the fold wants loading="lazy" and no hint at all; pushing it down the queue with fetchpriority=low on below-the-fold images is the complementary move. An image needed on the next navigation is the only case where prefetch is correct, and even then only when the connection can spare the bytes.

Decision tree selecting preload, fetchpriority, lazy loading or prefetch for an image A left-to-right decision tree. Every image is first tested for whether it is the Largest Contentful Paint candidate. If it is, a second test asks whether the Network panel Initiator column reads parser: yes leads to fetchpriority high on the img tag alone, no leads to a preload link with fetchpriority high. If the image is not the LCP candidate, a second test asks whether it is needed on this page at all: yes leads to lazy loading with no hint, no leads to prefetch gated on effective connection type. Which hint does this image get? Ask about LCP candidacy first, then about what the preload scanner can already see Image on the page Is it the LCP candidate? Initiator column already says parser? Needed on this page at all? fetchpriority=high on the img, no hint preload as=image fetch starts at 80 ms loading=lazy and no hint at all prefetch, gated on effectiveType yes no yes no yes no Exactly one image per page takes the preload branch — the confirmed LCP element, nothing else.

The two leaves on the right of that tree are where most regressions live. A team that preloads an image the parser could already see gains nothing and risks a duplicate fetch; a team that lazy-loads the LCP element pays the full penalty described in fixing lazy-loaded LCP image regressions. Never combine preload with loading="lazy" on the same asset: the preload wins, the bytes arrive immediately, and if the image never scrolls into view Chromium logs a “preloaded but not used” warning three seconds after load.

Deterministic Fix Protocol

  • [ ] 1. Identify the LCP element. Run a Lighthouse mobile audit. Under “Opportunities → Largest Contentful Paint element”, confirm it is an image (not text). Note whether it is an <img>, a CSS background-image, or a component-rendered element.
  • [ ] 2. Check parser discoverability. Open Chrome DevTools → Network tab → reload with cache disabled. Filter by Img. If the LCP image Initiator column shows script, fetch, or css rather than parser, the preload scanner cannot find it — preload is mandatory.
  • [ ] 3. Add the preload hint as the first <link> in <head>. Place it immediately after <meta charset> and <meta name="viewport">, before any stylesheet. Use as="image" and fetchpriority="high".
  • [ ] 4. Match MIME type with type attribute for WebP/AVIF. Add type="image/webp" (or type="image/avif") so browsers that do not support the format skip the hint rather than fetching and failing: <link rel="preload" as="image" href="/hero.avif" type="image/avif" fetchpriority="high">.
  • [ ] 5. Add crossorigin="anonymous" for CDN-hosted images. Without this, a cross-origin preload and the subsequent <img> fetch use different credential modes, triggering two network requests. The crossorigin attribute must match on both the <link> and the <img>.
  • [ ] 6. Do not preload more than one image. The browser schedules preloads competitively with render-blocking CSS and scripts. Multiple image preloads starve the critical path. Preload only the confirmed LCP element.
  • [ ] 7. Use prefetch for carousel next-slides and paginated gallery images. Gate dynamic injection behind a network quality check: skip prefetch on 2g or slow-2g connections via navigator.connection.effectiveType to avoid consuming limited bandwidth. Refer to the dynamic hint injection via JavaScript pattern for IntersectionObserver-triggered injection.
  • [ ] 8. Verify in DevTools. Network tab → right-click column headers → enable Priority. The LCP image must show Highest or High. Prefetched images must show Low or Idle. If preload shows Low, the as attribute is missing or wrong.
  • [ ] 9. Confirm no duplicate fetches. Filter Network by the image filename. You should see exactly one request. Two requests with identical URLs indicate a crossorigin mismatch or a framework double-render.
  • [ ] 10. Validate with PerformanceObserver. Paste the snippet below in DevTools Console to confirm fetch timing:
// Logs resource fetch duration for every image request.
// A preloaded LCP image should show fetchStart < 200 ms on a fast connection.
new PerformanceObserver((list) => {
  list.getEntries()
    .filter(e => e.initiatorType === 'link' || e.initiatorType === 'img')
    .forEach(e => {
      console.log(`${e.name} | start: ${Math.round(e.fetchStart)}ms | duration: ${Math.round(e.duration)}ms`);
    });
}).observe({ type: 'resource', buffered: true });

Why a crossorigin Mismatch Fetches the Image Twice

Step 5 above is the one teams skip, and it is worth understanding the mechanism rather than memorising the rule. The preload cache is not keyed on the URL alone. Chromium’s memory-cache entry for a preloaded resource records the URL, the destination derived from as, the request mode, and the credentials mode derived from the crossorigin attribute. A hint written as <link rel="preload" as="image" href="https://cdn.example.com/hero.webp"> with no crossorigin fetches in no-cors mode with credentials included. The matching <img crossorigin="anonymous" src="https://cdn.example.com/hero.webp"> asks for a CORS-mode fetch with credentials omitted. Two different keys, one lookup miss, and the browser dutifully re-downloads bytes it is already holding. Chrome surfaces this as “A preload for … is found, but is not used because the request credentials mode does not match” — one of several messages catalogued in the guide to preloaded but not used console warnings.

DevTools Network panel before and after fixing a crossorigin mismatch on a preloaded hero image Two stylised Chrome DevTools Network panels filtered to hero.webp. The upper panel, before the fix, lists the same file twice: once initiated by the preload link and once by the img element, 284 kilobytes transferred in total. The lower panel, after adding crossorigin anonymous to both elements, lists a single request of 142 kilobytes that starts at 80 milliseconds. Network panel, filtered to hero.webp Two rows for one image file is the whole tell — nothing else in DevTools reports it Before: crossorigin on the link element only Name Initiator Priority Size Time hero.webp link preload High 142 kB 310 ms hero.webp img element High 142 kB 295 ms 284 kB on the wire for one image: the link fetched with credentials, the img asked for anonymous. After: crossorigin="anonymous" on both elements Name Initiator Priority Size Time hero.webp link preload High 142 kB 80 ms One request, 142 kB: the img now resolves out of the preload cache and transfers 0 B.

Three rules follow from that key. First, crossorigin must be present on both elements or absent from both — matching matters more than which value you pick. Second, same-origin images need no crossorigin anywhere; adding it to only the <link> is how the mismatch usually gets introduced during a CDN migration. Third, if you do need CORS mode — because the image is drawn into a <canvas> and read back, or uploaded as a WebGL texture — the CDN must return Access-Control-Allow-Origin, otherwise the CORS fetch fails outright and the image never paints at all. Fonts behave the same way, which is why a font preload without crossorigin is the single most-reported duplicate fetch on the web.

The same key sensitivity explains the less obvious as failure: as="image" and no as at all produce different destinations, so a hint missing as will download the bytes at Low priority and then miss when the <img> looks for them. Two requests, one of them useless, and a warning in the console.

Before/After Metrics

These values reflect a typical single-page application where the LCP hero image was rendered by a JS component (late discovery) and moved to a <link rel="preload"> in <head>.

Metric Before (no hint) After (preload) How to verify
LCP 4.1 s 2.2 s Lighthouse mobile / CrUX field data
LCP image fetch start 1 600 ms 80 ms DevTools Network → Timing column
Priority shown in Network tab Low High DevTools → Priority column
Double-fetch events 2 (CORS mismatch) 0 Network filter by filename
Prefetch cache hit rate (next page) 0% 71% DevTools → Size column “(disk cache)”

A correctly preloaded LCP image should start fetching within the first 200 ms on a 3G Fast connection (WebPageTest default profile). If fetch start still exceeds 400 ms after adding preload, check that the <link> is not below a render-blocking stylesheet — reorder it above.

Engine Differences Worth Budgeting For

The preload half of this advice is portable; the prefetch half is not. Chromium has shipped imagesrcset/imagesizes on preload links since Chrome 73 and fetchpriority since Chrome 101, and it is the engine whose priority bands the numbers above were measured against. WebKit supports rel="preload" with the image-specific attributes and added fetchpriority in Safari 17.2, but has never shipped <link rel="prefetch">: the element parses, the hint is ignored, and the next navigation pays the full cost. Firefox implements prefetch for same-origin navigations and honours fetchpriority from version 119, but its image priority assignment differs from Chromium’s in the middle bands, so an image that shows High in Chrome may sit a band lower in Gecko. The practical consequences: treat preload as a cross-engine tool and prefetch as a Chromium-and-Gecko optimisation that must degrade silently, and never let a Safari user depend on prefetched bytes for a fast next page. The engine-by-engine priority comparison has the full band tables; verify any number you rely on against your own field data rather than a support matrix.

FAQ

Can I preload a responsive image with srcset?

Yes. Use the imagesrcset and imagesizes attributes on the <link> element. Without them, the browser preloads the href fallback and later re-fetches the correct srcset variant once layout is known, wasting one full round trip. imagesrcset mirrors the <img srcset> value; imagesizes mirrors <img sizes>.

Does prefetch work across same-origin navigations?

Yes, provided the server’s Cache-Control header allows reuse. A prefetched resource lands in the HTTP cache and is served from there on the next navigation as a cache hit (DevTools shows (disk cache) in the Size column). If the response carries no-store or max-age=0 without a validator, the prefetch is wasted because the browser cannot reuse it.

Will preloading images block other critical resources?

It can. The browser schedules High-priority requests competitively. Under HTTP/1.1 the connection pool is capped at 6 per origin; under HTTP/2 all streams share bandwidth on one connection. Adding multiple image preloads competes directly with render-blocking CSS and scripts. Limit image preloads to the single confirmed LCP element and use fetchpriority="high" only on that one asset to avoid priority inversion.

Should I preload an image that also has loading="lazy"?

No — the two directives state opposite intents. The preload starts an immediate High-priority fetch while loading="lazy" tells the browser the image is not needed until it approaches the viewport. Chromium resolves the conflict in favour of the preload, downloads the bytes eagerly, and then, if the image never scrolls into view, logs a “preloaded but not used” warning roughly three seconds after load. Pick one per image: preload for the LCP element, loading="lazy" for everything below the fold.

Does a prefetched image survive a cross-site navigation?

Usually not. Modern browsers partition the HTTP cache by top-level site, so an image prefetched while the user is on site A is stored under a key that includes A. Navigating to site B produces a miss for the identical URL, and the prefetch was pure waste. Prefetch pays off for same-site navigations — the next article, the next page of a gallery, the next slide in a carousel — and should never be budgeted for cross-site hops.

How do I preload an AVIF image with a WebP fallback?

Emit one <link> per format with a matching type attribute: type="image/avif" and type="image/webp". A browser skips any hint whose type it cannot decode, so the fallback costs nothing where AVIF is supported — except in browsers that support both, which will fetch both. Avoid the double fetch by negotiating on the server using the Accept request header and emitting a single preload for the format you will actually serve, or by keeping imagesrcset on a single format and letting <picture> handle format selection without a hint.


Related