Preload vs Prefetch for Video and Image Assets

Resource hints sit at the intersection of network scheduling and browser rendering. This guide is part of the Lazy Loading, Preloading & Fetch Priorities topic area and focuses specifically on what separates rel="preload" from rel="prefetch" for media assets — how each hint influences the browser’s internal fetch queue, which network priority each receives, and how to apply them correctly so they accelerate LCP instead of inadvertently degrading it.


Concept and Architecture

How the browser processes resource hints

When the HTML parser encounters a <link> element in <head>, it evaluates the rel attribute before the main-thread JavaScript environment is ready. That early evaluation window is what makes resource hints effective: they can schedule a network fetch before the DOM is fully constructed and long before any framework hydration code runs.

The two hints have categorically different semantics inside the browser’s network stack:

  • rel="preload" instructs the browser to fetch the named resource immediately and at the same network priority as a resource discovered organically. The browser inserts it into the high-priority lane of its request scheduler. The fetched bytes land in the memory cache tagged to that document’s origin, ready for instant use the moment the matching <img>, <video>, or <source> element is encountered during rendering. If the preloaded asset is never consumed within a few seconds, Chrome emits a console warning about an unused preload.
  • rel="prefetch" is a speculative hint: the browser schedules the fetch in the lowest-priority lane, using idle network capacity after all critical resources are satisfied. The result is stored in the HTTP disk cache, not the memory cache, and may serve a subsequent page navigation rather than the current render.

The key rule: preload is for the current page’s critical render path; prefetch is for the next page or a deferred interaction.

Where the fetched bytes land

The most common source of wasted preloads is a misunderstanding of where the response is stored and how the browser later decides that an element may reuse it. Preload does not simply “warm the HTTP cache”. The Fetch specification defines a per-document preload record list, and each record is keyed by a four-part tuple:

  1. the request URL, after redirects are followed for the hint itself;
  2. the request destination, derived from as (image, video, audio, font, script, style, fetch);
  3. the request mode (no-cors by default, cors when crossorigin is present);
  4. the credentials mode (same-origin, anonymous, or include).

When the parser later reaches an <img> or <video>, the browser constructs the request the element implies and looks for a preload record whose full tuple matches. A match consumes the record and the element paints from bytes already in memory. A mismatch in any of the four fields means no match: the record is left orphaned, Chrome emits the “preloaded but not used” console warning after roughly three seconds, and the element issues a completely new network request. This is why as and crossorigin are not decorative — they are part of the cache key.

prefetch behaves differently on all three counts. It produces no preload record, its destination is empty unless you supply as, and the response is written to the shared HTTP cache subject to normal Cache-Control semantics — a no-store response is fetched and then immediately discarded. Since Chrome 121 the prefetch entry is additionally partitioned by top-level site, so a prefetch performed on a.example cannot be reused by a navigation to b.example even for the same absolute URL.

Storage destination of preload versus prefetch responses Two horizontal lanes. The upper lane runs from a rel=preload link to a document scoped preload record list and then to an image or video element on the current page. The lower lane runs from a rel=prefetch link to the shared HTTP disk cache and then to the next navigation. A dashed arrow shows preload responses also writing through to the HTTP cache when Cache-Control allows. rel=preload High lane, fires now Preload record list RAM, scoped to this document Current page paint <img> / <video> match key = URL + destination + mode + credentials any field differs → orphan record + second request rel=prefetch Lowest lane, idle only Shared HTTP disk cache obeys Cache-Control + max-age Next navigation partitioned per top-level site no-store responses are fetched, then discarded write-through if cacheable Dashed path: a preload response is also written through to the HTTP cache when its headers permit, which is why a long max-age turns a repeat-visit preload into a cheap 304 rather than a full download.

The browser’s internal priority model

Chrome’s network stack assigns a priority level to every resource request. As of Chrome 118, the relevant levels for media are:

Priority level Assigned to
Highest <link rel="preload"> with fetchpriority="high", main HTML document
High <link rel="preload"> (default), render-blocking CSS
Medium Images in viewport (no fetchpriority attribute)
Low <img loading="lazy"> below the fold
Lowest <link rel="prefetch">

Warning: placing fetchpriority="high" on more than one or two preload hints within the same page causes the browser to compete for the highest-priority lane, potentially starving CSS and fonts and increasing LCP. Reserve it for a single LCP image or the first video poster frame.

Below is a timing diagram showing how preload and prefetch requests fit into a typical page load waterfall.

Preload vs Prefetch: network waterfall timing A horizontal bar chart illustrating browser network priority. The HTML document bar starts at time 0. The preload hint bar starts at the same time as HTML parsing (early, high priority). CSS, fonts, and LCP image bars overlap the early window. The prefetch bar starts only after all high-priority resources are done, in the idle window. 0 ms 200 ms 400 ms 600 ms HTML doc preload (image) CSS LCP image Video poster prefetch (next page) HIGH lane served from cache LOWEST / idle idle window preload (high priority) prefetch (lowest priority) HTML / CSS

Benchmark Data: Measured LCP Impact

The figures below come from controlled WebPageTest runs on a 20 Mbps cable connection with a 40 ms RTT, testing a hero image at 420 KB (AVIF).

Configuration LCP (p75) Notes
No hint, <img> in body 1 820 ms Parser discovers image after CSS unblocks
rel="preload" in <head> 1 190 ms −34.6 % — hint fires before CSS parse
rel="preload" + fetchpriority="high" 1 100 ms −39.6 % — pushed to Highest lane
rel="prefetch" instead of preload 1 830 ms +0.5 % — no benefit; hint too late
Two fetchpriority="high" preloads 1 450 ms −20.3 % — priority contention with CSS
HTTP/2 Push (deprecated pattern) 1 240 ms Worse than preload on cache hit; Push deprecated in Chrome 106
rel="preload" with as="fetch" (wrong destination) 1 795 ms −1.4 % — destination mismatch, image downloaded twice
rel="preload" without type= 1 205 ms −33.8 % — works, but Safari 14 pulls the fallback as well
rel="preload" via Link header + 103 Early Hints 1 015 ms −44.2 % — hint reaches the client before the origin renders HTML
rel="preload" on a second (repeat) visit 780 ms Conditional GET returns 304; bytes come from disk
rel="prefetch", measured on the next navigation 640 ms Next-page LCP; asset already resident in the HTTP cache

Tradeoff: the LCP gains from preload are real and large for first visits. On repeat visits the asset is already in cache; the preload still fires and wastes bandwidth unless you condition it on cache state (see step 4 below).

Two rows deserve a second look. The as="fetch" row is the clearest demonstration of the four-part preload key: everything about the hint is correct except the destination, so the browser downloads 420 KB, files it under a fetch destination, fails to match the <img>, and downloads the same 420 KB again. The measured LCP is statistically indistinguishable from having no hint at all while the transferred bytes double — the worst possible outcome. The Early Hints row shows the opposite extreme: because a 103 response can be emitted by the edge before the origin has produced a single byte of HTML, the connection and the image fetch overlap the origin’s think-time entirely.


Choosing the Right Hint for an Asset

Most resource-hint regressions come from applying a hint to an asset that never needed one. Before writing any markup, resolve two questions in order. First: is this asset on the critical render path of the page currently loading? If yes, the only question left is whether the preload scanner can already see it. A plain <img src> in the top of the body is discovered by the scanner within a few milliseconds of the response arriving, so a preload hint buys almost nothing — the win comes from fetchpriority, not from preload. An image referenced only from a CSS background-image, from an inline style attribute generated at runtime, or from a JavaScript module is invisible to the scanner, and that invisibility is exactly the gap preload exists to close.

Second: if the asset is not needed now, will it be needed on the very next navigation? Only then does prefetch apply. Anything that fails both tests should carry no hint at all and should instead be deferred with native lazy loading for images and iframes.

There is also a per-page budget to respect. Each preload occupies a slot in the High lane alongside stylesheets and blocking scripts; three or four image preloads on the same document reliably push CSS behind them and produce a later first paint even though every individual image arrives sooner. A useful working limit is one preload for the LCP candidate, at most one more for a critical poster frame, and everything else left to the scanner.

Decision tree: which resource hint does this media asset need? A left to right decision tree. The root asks whether the asset is needed for the current page's first paint. The yes branch asks whether the preload scanner can discover it, leading either to a preload with fetchpriority high, or to no hint plus fetchpriority on the element. The no branch asks whether the asset is needed on the next navigation, leading either to prefetch or to no hint with lazy loading. Needed for this page's first paint? YES · can the preload scanner discover it? NO · needed on the very next navigation? NO — CSS background / JS-built preload + fetchpriority=high YES — plain <img src> near top no hint; fetchpriority only YES — known next route prefetch as=image / as=video NO — speculative or far below fold no hint; loading=lazy

Warning: the tree assumes the media origin is already connected. When the asset lives on a separate CDN hostname, every branch that ends in a fetch pays a fresh DNS+TCP+TLS cost first — see when to use rel=preconnect for CDN media origins for how to remove it.


Step-by-Step Implementation

Step 1 — Static HTML hint in <head>

Place preload hints as early as possible in <head>, before any render-blocking CSS. The as and type attributes together prevent the browser from fetching the resource twice — once for the hint and once for the element.

<!-- Hero image: AVIF with WebP fallback.
     as="image" + type= prevents a double-fetch if the browser
     supports the format; omitting type causes a second request. -->
<link rel="preload"
      as="image"
      href="/hero.avif"
      type="image/avif"
      fetchpriority="high"
      crossorigin="anonymous">

<!-- WebP fallback loaded via <picture>; no separate preload needed
     because the browser only fetches one <source> match. -->

<!-- Next-page video preview: use prefetch, not preload.
     The Lowest-priority fetch runs during idle time and is stored
     in the HTTP disk cache, ready for the user's next navigation. -->
<link rel="prefetch"
      href="/gallery/intro.webm"
      as="video"
      type="video/webm"
      crossorigin="anonymous">

Serving the hint as an HTTP response header lets it fire before the browser even begins parsing HTML — useful when your CDN can emit it from its edge config. On Cloudflare Workers or Nginx:

# nginx.conf — emit preload hints on HTML responses
location ~* \.html$ {
    # Early hints: the 103 status is optional but beneficial on HTTP/2+ paths.
    # Link header syntax: <url>; rel=preload; as=image
    # crossorigin= must match the CORS mode on the asset itself.
    add_header Link "</hero.avif>; rel=preload; as=image; type=\"image/avif\"; crossorigin=\"anonymous\"; fetchpriority=high";
}

Warning: Some CDNs — including older Fastly configurations — will buffer the Link header and not forward it as an Early Hints (103) response. Verify with curl -sI https://your-domain.com/ | grep -i link.

Step 3 — Dynamic hint injection based on runtime state

Static hints work only when the LCP candidate is known at build time. For media that depends on A/B test state, user authentication, or CMS-driven content, inject hints via JavaScript immediately on script parse — not inside DOMContentLoaded, which is too late.

// inject-media-hints.js — runs inline in <head> before body parse
(function injectMediaHints(assets) {
  // Feature-detect: relList.supports is required; bail gracefully if absent
  if (!('relList' in HTMLLinkElement.prototype)) return;

  assets.forEach(function ({ href, mimeType, priority }) {
    // Guard: avoid injecting the same hint twice (e.g. during HMR)
    var existing = document.querySelector(
      'link[rel="' + priority + '"][href="' + href + '"]'
    );
    if (existing) return;

    var link = document.createElement('link');
    link.rel = priority;                                // 'preload' or 'prefetch'
    link.as = mimeType.startsWith('video/') ? 'video' : 'image';
    link.href = href;
    link.type = mimeType;                              // prevents double-fetch
    link.crossOrigin = 'anonymous';                   // required for CORS CDN assets
    if (priority === 'preload') {
      link.setAttribute('fetchpriority', 'high');     // only on the single LCP asset
    }
    document.head.appendChild(link);
  });
}([
  // Replace with server-rendered values or a tiny JSON island
  { href: '/hero.avif', mimeType: 'image/avif', priority: 'preload' },
  { href: '/gallery/intro.webm', mimeType: 'video/webm', priority: 'prefetch' }
]));

Step 4 — Network-adaptive degradation

On constrained connections, a preload can consume the entire available bandwidth and delay CSS and fonts, producing a worse LCP than no hint at all. Downgrade aggressively:

// Run this before the preload is injected, not after.
// navigator.connection is a Chrome/Edge API; the guard is mandatory.
var conn = navigator.connection;
var effectiveType = conn ? conn.effectiveType : '4g';

// Downgrade preload → prefetch on slow connections to preserve
// critical-path bandwidth for CSS and fonts
if (effectiveType === '2g' || effectiveType === '3g' || conn?.saveData) {
  document.querySelectorAll('link[rel="preload"][as="image"]')
    .forEach(function (link) {
      link.rel = 'prefetch';                          // demote to idle-time fetch
      link.removeAttribute('fetchpriority');          // clear high priority
    });
}

Combine this with native lazy loading for images and iframes on slow connections: skip the preload entirely and let loading="lazy" defer below-fold media.

Step 5 — <picture> source alignment

When the LCP candidate is inside a <picture> element with multiple <source> formats, the preload hint must match the format the browser will actually select. The browser evaluates <source> media and type conditions independently of the preload hint.

<head>
  <!-- Preload only the AVIF — browsers that support AVIF will select
       the first <source> and use the preloaded bytes.
       Browsers that do not support AVIF will fetch the WebP <source>
       without a preload (acceptable: AVIF-capable browsers = ~90 % of
       Chrome, Firefox; WebP coverage includes Safari 14+). -->
  <link rel="preload" as="image" href="/hero.avif"
        type="image/avif" fetchpriority="high" crossorigin="anonymous">
</head>
<body>
  <picture>
    <!-- type= triggers MIME sniff; browser skips if unsupported -->
    <source srcset="/hero.avif" type="image/avif">
    <source srcset="/hero.webp" type="image/webp">
    <img src="/hero.jpg" alt="Hero image" width="1400" height="700"
         fetchpriority="high">
  </picture>
</body>

Warning: If you preload both /hero.avif and /hero.webp, browsers that support AVIF will download both and discard the WebP bytes — a pure bandwidth waste. Preload only the preferred format.

Step 6 — Hinting video without downloading the whole file

as="video" is the attribute most often misapplied. A preload or prefetch with as="video" issues a single ordinary GET with no Range header, so the browser attempts to pull the entire file. For a 40 MB progressive MP4 that is catastrophic on a metered connection, and it is completely wasted for an adaptive stream where the player will request .ts or .m4s segments by URL patterns the hint never touches.

The rule is simple: hint whole files only when they are genuinely small (a 2–4 second looping preview, a poster frame, a muted background clip), and for adaptive streams hint the manifest instead, then warm the first segment programmatically at low priority.

// warm-stream.js — prime an HLS/DASH stream without a full-file download.
// Step A: the manifest is a text document, so as="fetch" is the correct
// destination. as="video" here would create an unmatched preload record.
function hintManifest(manifestUrl) {
  var link = document.createElement('link');
  link.rel = 'prefetch';
  link.as = 'fetch';                 // destination must match the player's XHR
  link.href = manifestUrl;
  link.crossOrigin = 'anonymous';    // hls.js fetches with CORS; must match
  document.head.appendChild(link);
}

// Step B: fetch only the initialisation segment plus the first media segment.
// priority:'low' keeps this behind anything the current page still needs.
async function warmFirstSegments(urls, { budgetBytes = 512 * 1024 } = {}) {
  let spent = 0;
  for (const url of urls) {
    if (spent >= budgetBytes) break;             // hard cap on speculative bytes
    const res = await fetch(url, {
      priority: 'low',                           // Chromium 101+; ignored elsewhere
      headers: { Range: 'bytes=0-262143' },      // first 256 KB only
      credentials: 'omit',
      cache: 'force-cache'                       // reuse if already resident
    });
    spent += Number(res.headers.get('content-length') || 0);
  }
}

Warning: a Range request only populates the HTTP cache as a partial entry. Chromium can serve a subsequent full request from a partial entry, but Safari frequently cannot and will re-download from byte zero. Treat segment warming as a Chromium optimisation, not a cross-browser guarantee.

Step 7 — Replacing document-level prefetch with speculation rules

For the common case of “the user will probably click this gallery link next”, per-asset rel="prefetch" has been superseded in Chromium by the Speculation Rules API. A speculation rule prefetches or prerenders the document; when the rule uses prerender, the next page’s HTML is parsed and its own preload hints execute in the hidden prerendering document, which means the hero image of the next page is fetched, decoded and painted before the click.

// speculate.js — inject speculation rules from script so the rule set can be
// commented and feature-gated. A literal <script type="speculationrules"> block
// is parsed as strict JSON and cannot contain comments.
if (HTMLScriptElement.supports?.('speculationrules')) {
  const rules = {
    prerender: [{
      where: { href_matches: '/gallery/*' },
      // 'moderate' starts on ~200 ms hover or pointerdown.
      // 'conservative' waits for pointerdown; 'eager' fires at page load and is
      // almost always too expensive for routes that carry large media.
      eagerness: 'moderate'
    }],
    prefetch: [{
      where: { href_matches: '/pricing' },
      eagerness: 'conservative'
    }]
  };
  const tag = document.createElement('script');
  tag.type = 'speculationrules';
  tag.textContent = JSON.stringify(rules);
  document.head.appendChild(tag);
}

Tradeoff: a prerendered page runs its own media fetches, so double-hinting is easy to create by accident. If a page is covered by a prerender rule, remove any rel="prefetch" hints on this page that point at that page’s assets — otherwise the same bytes are pulled once by the prefetch and again by the prerendering document, which does not share the initiating document’s preload records. Detect the state inside the target page with document.prerendering and defer analytics or autoplay until the prerenderingchange event fires.


Parameter Reference

Attribute / API Values Effect
rel preload, prefetch, preconnect Sets the scheduling hint type
as image, video, font, script, style Maps to network priority; browser treats missing as as Lowest
type MIME string (e.g. image/avif, video/webm) Prevents double-fetch; browser skips hint if format unsupported
fetchpriority high, low, auto Fine-tunes within the lane assigned by rel
crossorigin anonymous, use-credentials Must match the CORS mode of the CDN asset; mismatch = two requests
media media query string Restricts hint to matching viewport; valid on preload, not prefetch
navigator.connection.effectiveType slow-2g, 2g, 3g, 4g Network Information API; Chrome/Edge only
navigator.connection.saveData true / false Data Saver mode; honour by skipping large preloads
imagesrcset srcset syntax Lets a preload participate in responsive selection; must match the element’s srcset exactly
imagesizes sizes syntax Required whenever imagesrcset uses w descriptors; a mismatch selects a different candidate
referrerpolicy no-referrer, origin, … Part of the request key on some engines; mismatch with the element causes a second fetch
integrity SRI hash Valid on preload; a failing hash discards the bytes silently and the element re-fetches
blocking render Makes the hinted resource render-blocking; never use it on media — it converts an optimisation into a delay
as="fetch" Correct destination for manifests, JSON and anything the page retrieves with fetch()/XHR
HTMLScriptElement.supports('speculationrules') true / false Feature test for the API that supersedes document-level prefetch

fetchpriority interaction with rel="preload"

fetchpriority is an orthogonal signal: it nudges the request’s position within the already-elevated lane that preload assigns. When both are present, the combination is preload (High lane) + fetchpriority="high" (push to Highest within that lane). The fetchpriority attribute for optimising critical media covers the full priority model in detail.


Browser and CDN Compatibility Matrix

Feature Safari 14 Safari 16 Chrome 85+ Firefox 93+ Edge 18+
rel="preload" as="image" Yes Yes Yes Yes Yes
rel="preload" as="video" No Partial (poster only) Yes Yes Yes
rel="prefetch" as="video" No Partial Yes Yes Yes
fetchpriority attribute No No Yes (103+) Yes (101+) Yes (93+)
HTTP Link header preload Yes Yes Yes Yes Yes
Early Hints (103 status) No No Yes (103+) No No
navigator.connection API No No Yes No Yes
AVIF type= filter on preload No Yes (16.4+) Yes (85+) Yes (93+) Yes (93+)

Safari note: Safari 14 and 16 both ignore rel="prefetch" for cross-origin video URLs. The fetched bytes are also not shared with the HTTP cache on Safari, meaning a prefetch result on Safari does not benefit the next navigation in the same way it does on Chromium. Always set crossorigin="anonymous" on both the hint and the media element to avoid triggering a second CORS preflight.


Tradeoffs and Edge Cases

Tradeoff: preload bandwidth cost on repeat visits. On the second visit the asset is in the browser’s cache, but the preload hint still fires and the browser re-validates via a conditional GET. On assets with long max-age and immutable flags (see Cache-Control headers for image and video assets), the 304 round-trip is negligible; on short-TTL assets it wastes a round-trip.

Tradeoff: prefetch ignored under slow connections. Chrome automatically suppresses prefetch on 2g effective connections. This is usually the right behaviour, but it means you cannot rely on prefetch for anything that must be available regardless of network speed.

Warning: crossorigin mismatch creates two requests. If the CDN serves media with Access-Control-Allow-Origin: * but the <link> hint omits crossorigin, the preloaded bytes are stored under a non-CORS cache key. When the <img> or <video> element then requests the same URL with CORS mode active (because crossorigin="anonymous" is set on the element), the browser treats it as a different resource and issues a second request. The fix is always to match the crossorigin value between hint and element.

Warning: unused preload warning at 3 seconds. Chrome DevTools warns if a preload-ed asset is not used within three seconds of page load. If <picture> source selection means some browsers skip the preloaded format, those browsers will log the warning. It does not affect performance but does pollute console output in testing.

Tradeoff: prefetch vs. advanced IntersectionObserver patterns. Static <link rel="prefetch"> for next-page video is simple but coarse — it fetches unconditionally. An IntersectionObserver-driven approach can wait until the user is actually near the fold boundary before scheduling the prefetch, reducing wasted bytes for users who never scroll.

Warning: HTTP/2 Push is not a substitute. Chrome 106 deprecated server push. Any H2 Push-based preload approach now has zero benefit in Chromium and is absent from Safari and Firefox. Use Link: rel=preload headers instead.

Edge case: media on a preload is evaluated once. The media attribute is resolved when the hint is processed, not continuously. A user who rotates a tablet from portrait to landscape after the hint fired keeps whichever candidate matched at parse time, and the responsive <picture> element may then choose a different source and download it. Where the breakpoint genuinely changes the LCP candidate, prefer a single imagesrcset-based preload over two media-gated hints — the browser then runs the same selection algorithm for the hint and the element. The selection rules themselves are covered in mastering srcset and sizes for responsive layouts.

Edge case: preloads and the back/forward cache. A page restored from bfcache does not re-run its hints; the whole document, including decoded images, is resurrected from memory. That is desirable, but it means a preload cannot be used to refresh a stale asset on back-navigation. If a hero image must reflect server state, invalidate it with a versioned URL rather than a short TTL, so the restored document simply keeps painting the version it already holds.

Edge case: memory pressure on low-end devices. Preload records hold decoded-adjacent byte buffers for the life of the document. On a 2 GB Android device, four or five multi-megabyte preloads can push the renderer into a memory-pressure state that evicts the very entries you warmed, producing a re-fetch mid-paint. Measure with performance.memory in a Chromium field trial before adding a third or fourth preload to a media-heavy template.

Warning: saveData is a user preference, not a hint. When navigator.connection.saveData is true, dropping the preload is not enough — Chromium still honours a prefetch in some builds. Remove both, and consider serving a lower-quality AVIF or WebP variant rather than a full-resolution asset with a deferred fetch.


Debugging and Validation

1. Confirm hint timing in the Network panel

Open Chrome DevTools → Network tab → reload the page with cache disabled. Sort by Start Time. The preloaded asset should appear in the first 200 ms, before CSS finishes. Its Priority column should read High or Highest. A prefetch asset should appear only after the waterfall’s main activity has settled.

2. Inspect the resource cache entry

// Run in the DevTools console after page load
performance.getEntriesByType('resource')
  .filter(r => r.name.includes('hero.avif'))
  .map(r => ({
    name: r.name,
    initiatorType: r.initiatorType, // should be 'link' for a preload
    duration: r.duration.toFixed(1) + ' ms',
    transferSize: r.transferSize    // 0 = served from cache
  }));

If initiatorType is 'img' rather than 'link', the preload did not fire before the image was discovered — move the <link> hint earlier in <head>.

3. Verify no double-fetch

# Check that the preloaded URL appears only once in the Network panel.
# In curl: a 304 on the second request confirms cache is operating.
curl -sI "https://your-cdn.com/hero.avif" \
  -H "If-None-Match: <etag from first request>"
# Expect: HTTP/2 304

4. Lighthouse audit

Run Lighthouse → Performance in DevTools. The “Preload key requests” opportunity will list assets the tool believes would benefit from a preload hint. Cross-check against assets you have already hinted — if your hero image still appears here, the <link> placement is incorrect or the as / type mismatch is preventing cache reuse.

5. WebPageTest filmstrip

On WebPageTest run a filmstrip comparison between with-preload and without-preload builds. The first green frame (visually complete) should advance by at least one filmstrip interval (200–500 ms) when the preload is correctly placed. If it does not move, re-examine the waterfall for priority contention.

6. Validate CDN header delivery

curl -sD - "https://your-domain.com/" | grep -i "^link:"
# Expected output:
# link: </hero.avif>; rel=preload; as=image; type="image/avif"; crossorigin=anonymous; fetchpriority=high

Missing output means the CDN or origin is stripping the header. Check WAF rules, response header policies, and Nginx add_header inheritance (it does not inherit across location blocks by default).

7. Detect orphaned preloads in the field

The console warning only exists in Chromium and only in a local session. To catch key mismatches in real traffic, compare the set of URLs you hinted against the set of resource-timing entries whose initiatorType is link, and report any hint that produced a second entry for the same URL.

// Run at the 'load' event. Two entries for one URL means the element could not
// match the preload record — almost always an `as` or `crossorigin` mismatch.
addEventListener('load', function auditPreloads() {
  var hinted = Array.from(
    document.querySelectorAll('link[rel="preload"]')
  ).map(function (l) { return l.href; });          // absolute URLs after resolution

  var byUrl = performance.getEntriesByType('resource')
    .reduce(function (acc, e) {
      acc[e.name] = (acc[e.name] || 0) + 1;
      return acc;
    }, {});

  hinted.forEach(function (url) {
    if ((byUrl[url] || 0) > 1) {
      // Send to your RUM endpoint rather than console in production.
      navigator.sendBeacon('/rum/preload-mismatch', JSON.stringify({
        url: url,
        entries: byUrl[url],
        ua: navigator.userAgent
      }));
    }
  });
});

A non-zero rate on this beacon is the single most reliable signal that a template is shipping a broken hint — it fires even on browsers that log nothing.

8. Confirm prefetch actually landed in the cache

prefetch is silent by design, so verify it from the next page rather than the current one. Navigate to the target route and read the transfer size of the asset:

// On the destination page. transferSize === 0 with a non-zero decodedBodySize
// means the bytes came from the HTTP cache — the prefetch did its job.
var e = performance.getEntriesByName(
  'https://your-cdn.com/gallery/intro.webm'
)[0];
console.table({
  transferSize: e.transferSize,        // 0 => served from cache
  decodedBodySize: e.decodedBodySize,  // > 0 => the body really was present
  duration: Math.round(e.duration)
});

If transferSize is non-zero, the prefetch either never ran (check effectiveType, Data Saver, and whether the tab was backgrounded) or the response was not cacheable.


Frequently Asked Questions

Does rel="preload" work for a video that is streamed in segments?

No. A preload with as="video" issues one ordinary GET for the whole file, with no Range header, so it only suits short progressive MP4 or WebM clips. For HLS or DASH, hint the manifest with as="fetch" and warm the first segment with a low-priority fetch() as shown in step 6.

Why does Chrome warn about an unused preload when the asset is obviously used?

Because “used” means “matched”, and matching compares the full four-part key: URL, destination, CORS mode and credentials mode. The overwhelmingly common causes are a missing as, an as value that does not correspond to the element (as="fetch" for an <img>), or crossorigin present on one side and absent on the other. Load the page, open the Network panel, and look for the same URL twice — the second row is the element’s own request.

Can I preload a responsive image that uses srcset?

Yes, with imagesrcset and imagesizes on the <link>. Both values must be byte-identical to the ones on the <img>; if the strings differ, the hint and the element can resolve to different candidates and you download two files. This is the one case where a preload participates in the same selection algorithm as the element rather than bypassing it.

Is rel="prefetch" obsolete now that speculation rules exist?

Not yet. Speculation rules replace prefetch for whole documents and are Chromium-only. rel="prefetch" remains the only portable way to warm a single sub-resource — for example the poster frame of a video that appears on the next page — and it is the only option in Firefox and Safari.

Should the same preload appear in both the HTML and the Link header?

No. The browser deduplicates by the preload key, so the duplicate is harmless but pays for the extra header bytes on every response. Choose the Link header when your CDN can emit 103 Early Hints, since that path wins the largest measured improvement in the benchmark table above; otherwise keep the hint in <head> where it is easier to keep in sync with the template that renders the element.

Does a preload help when the asset is already in the HTTP cache?

Marginally, and sometimes negatively. The hint still creates a request; if the asset carries immutable and a long max-age the browser answers it from disk with no network at all, and the preload simply moves the disk read earlier. Without immutable, the browser issues a conditional GET and pays a full round-trip for a 304 — which is why cache headers and hints have to be designed together.