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: preload vs prefetch timing A horizontal waterfall chart showing three rows. Row 1: critical CSS and JS fetch from 0 to 200ms. Row 2 (preloaded image): fetch starts at 10ms and completes at 300ms, before DOMContentLoaded at 400ms. Row 3 (prefetched image): fetch starts after DOMContentLoaded at 420ms and completes at 700ms. 0 ms 100 ms 200 ms 300 ms 400 ms 500 ms DCL CSS/JS critical CSS + JS (0–200 ms) Preload preloaded image (10–290 ms) ✓ before DCL Prefetch prefetched (420–560 ms) Preload — mandatory, High priority Prefetch — opportunistic, Low priority

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"
>

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 });

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.

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.


Related