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.
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.
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 CSSbackground-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 imageInitiatorcolumn showsscript,fetch, orcssrather thanparser, the preload scanner cannot find it —preloadis 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. Useas="image"andfetchpriority="high". - [ ] 4. Match MIME type with
typeattribute for WebP/AVIF. Addtype="image/webp"(ortype="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. Thecrossoriginattribute 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
prefetchfor carousel next-slides and paginated gallery images. Gate dynamic injection behind a network quality check: skip prefetch on2gorslow-2gconnections vianavigator.connection.effectiveTypeto avoid consuming limited bandwidth. Refer to the dynamic hint injection via JavaScript pattern forIntersectionObserver-triggered injection. - [ ] 8. Verify in DevTools. Network tab → right-click column headers → enable
Priority. The LCP image must showHighestorHigh. Prefetched images must showLoworIdle. IfpreloadshowsLow, theasattribute 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
crossoriginmismatch 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.
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
- Mastering Link Rel Preload & Prefetch — parent section
- Preloading Critical Above-the-Fold Assets
- How Browser Fetch Priority Affects LCP — priority queue deep-dive