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.
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.
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 |
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).
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">
Step 2 — HTTP Link header injection (CDN / server)
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.
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 |
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.
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).
Related
- When to use
rel=preconnectfor CDN media origins — pair preconnect with preload to eliminate the TCP/TLS handshake cost - Using
fetchpriorityto optimise critical media — fine-tune request priority within the lane preload establishes - Native lazy loading for images and iframes — combine with preload for above-fold assets and lazy for below-fold
- Advanced IntersectionObserver patterns for media — trigger prefetch dynamically as users scroll toward media sections
- Cache-Control headers for image and video assets — set
immutableand longmax-ageto make preload cache hits free on repeat visits