Preloading Video & Media Streams
Video is usually the largest thing a page can put on the wire, and it is scheduled by rules that have almost nothing in common with the ones governing images, scripts and stylesheets. A <video> element does not issue one request for one response: it opens a byte-range conversation that can span dozens of requests, run for minutes, and re-open every time the user seeks. It is dispatched in a low priority band, which sounds reassuring until you notice that band membership only decides who goes first — it does not stop a long-running transfer from occupying the congestion window for the entire duration of the load.
The result is a failure mode that almost every media-heavy page ships with at least once: a below-the-fold product video, left on the browser’s default preload behaviour, quietly pulls three megabytes while the hero image that determines Largest Contentful Paint waits behind it. Nothing in the waterfall looks broken. The video request is at Low. The image request started on time. The image simply takes three times longer than it should, because the two of them are sharing a 5 Mbps downlink.
This page covers what the preload attribute is actually specified to do (and where each engine deviates), the byte-range protocol underneath media loading and why container layout changes the request count, how media competes with the critical path over HTTP/1.1, HTTP/2 and HTTP/3, a step-by-step rollout for progressive files and for adaptive streams, a verification workflow that proves the byte budget held, and the gotchas — Safari’s range requirement, service worker interception, Save-Data clamping — that turn a correct configuration into a broken one.
What preload is specified to do
preload is an enumerated attribute on HTMLMediaElement with three keywords, defined in the HTML Standard as hints to the user agent about what the author thinks will lead to the best user experience. The spec is unusually explicit that the attribute is advisory: a user agent may ignore it entirely, and it is permitted to fetch more or less than the keyword suggests based on connection quality, device class or user settings.
none— the author expects the user probably will not need the media resource, or the server wants to minimise unnecessary traffic. No media data is fetched.networkStatestays atNETWORK_EMPTYuntil something callsload()orplay().metadata— the author expects the user does not need the resource yet, but fetching its metadata (dimensions, track list, duration, and for a seekable container the index) is reasonable. The target isreadyStateHAVE_METADATA(1).auto— the author expects downloading the whole resource without user activation to be reasonable. The browser buffers ahead until its own heuristic says it has enough, then firessuspendand drops toNETWORK_IDLE.
Two clauses matter more than the keyword table. First, the missing value default and the invalid value default are implementation-defined; the spec merely suggests metadata. An element with no preload attribute therefore behaves differently across engines, which is why “we didn’t set anything” is never a description of your loading behaviour. Second, autoplay overrides preload: if a media element is going to play automatically it must buffer, so the hint is moot. The spec calls this out directly, and it is the single most common reason a preload="none" element still floods the network.
The two state machines you observe this through are networkState (NETWORK_EMPTY 0, NETWORK_IDLE 1, NETWORK_LOADING 2, NETWORK_NO_SOURCE 3) and readyState (HAVE_NOTHING 0, HAVE_METADATA 1, HAVE_CURRENT_DATA 2, HAVE_FUTURE_DATA 3, HAVE_ENOUGH_DATA 4). A preload keyword is, operationally, a statement about which readyState the browser should climb to before it stops fetching. The table below is the same 90-second, 12 MB H.264 hero measured under all three keywords on one throttled profile.
Engine differences that change the default
| Behaviour | Chromium (Blink) | WebKit (Safari) | Gecko (Firefox) |
|---|---|---|---|
Missing preload attribute |
Treated as metadata |
auto on macOS; effectively none on iOS until a gesture |
Treated as metadata |
preload="auto" honoured |
Yes, until an internal buffer heuristic fires suspend |
Yes on macOS; clamped on iOS and in Low Power Mode | Yes, bounded by the readahead limit (60 s of media by default) |
preload="metadata" fetch shape |
Head range, plus a tail range if moov is at the end |
Same, but refuses to play at all without 206 support |
Same; the media cache stores the fetched ranges on disk |
| Save-Data / data saver | auto is downgraded toward metadata |
Low Power Mode suppresses preloading | Honours the user’s autoplay and data settings |
| Media Source Extensions | Yes | macOS yes; iPhone via Managed Media Source (iOS 17.1+) | Yes |
The practical consequence: there is no cross-engine default. On macOS Safari an attribute-less <video> behaves like preload="auto"; in Chrome and Firefox it behaves like metadata; on an iPhone it behaves like none until the user touches it. If your byte budget depends on the browser guessing correctly, it will hold on two engines out of three. Always write the keyword.
Byte ranges: the protocol underneath media loading
Media loading is not a single fetch. A media element asks for a slice, parses what came back, and decides what to ask for next — which is why a <video> produces several rows in the Network panel for one src. The first request carries an open-ended Range: bytes=0-; a correctly configured origin answers 206 Partial Content with Content-Range: bytes 0-524287/12582912 and advertises Accept-Ranges: bytes. From there the loader can cancel mid-stream and reissue a new range at any offset, which is exactly what seeking is.
Container layout decides how many round trips this takes. In an MP4/ISOBMFF file the moov atom holds the sample tables — every timestamp, every byte offset, the codec configuration — and encoders write it after mdat by default because sizes are only known once encoding finishes. A browser that reaches HAVE_METADATA therefore has to find moov at the end of a 12 MB file, which costs an extra request and an extra round trip before a single frame can be decoded. Remuxing with faststart moves moov in front of mdat and collapses the exchange to one request.
Three properties of this exchange drive most of the tuning you will do:
- A
200answer defeats the whole model. If the origin ignoresRangeand returns the entire body, the loader cannot ask for a slice —preload="metadata"becomes “download 12 MB”, and seeking degrades to re-downloading from zero. Safari goes further and refuses to play at all. - Partial responses are cached as sparse entries. Chromium stores
206bodies in a sparse HTTP cache entry keyed by URL, so a later range that overlaps an already-cached region is served locally. This interacts with revalidation exactly as described in cache interaction and stale-while-revalidate: change theETagmid-session and the browser discards the accumulated ranges. - Every range is a separate scheduler entry. Each one is dispatched, prioritised and counted independently, so a seek-heavy session produces a request pattern that looks nothing like the single row you see for an image.
How media competes with the critical path
Chromium assigns media requests the Low priority band, and DevTools shows them as such. That is the correct band — nothing on a media transfer should outrank a stylesheet — but band membership is a dispatch decision, not a bandwidth quota. Once a media range is in flight, it participates in congestion control like any other stream, and because it is long-lived it accumulates far more of the connection’s capacity than a 40 KB script ever will. The priority queue model tells you who starts first; it does not tell you who finishes first.
The protocol underneath changes the shape of the damage:
- HTTP/1.1 — the media transfer occupies one of the six per-host connections for its entire life. On a page that shards assets across a couple of hostnames this is often the most visible cost: a single video can remove a sixth of the parallelism available to everything else on that host.
- HTTP/2 — the video shares the connection, and whether it yields depends on the server implementing stream prioritisation faithfully. Many do not; a low-weight stream that has already been granted flow-control window continues to consume it. This is the same class of problem covered in HTTP/2 stream prioritisation and weighting.
- HTTP/3 — per-stream loss recovery removes head-of-line blocking between the video and your CSS, but not bandwidth contention. QUIC still has one congestion window per connection, so the video is competing for exactly the same capacity; see mitigating head-of-line blocking for why the two are separate problems.
The before-and-after below is one real template: an article page with a 420 KB AVIF hero (the LCP element) and a 12 MB product demo below the fold, on a 5 Mbps profile.
Nothing about the hero image changed between the two runs. It was discovered at the same moment, dispatched into the same band, served by the same origin. The only difference is that in the second run it was not sharing 5 Mbps with a video nobody had asked to watch.
Spec and API reference
Attributes and members that affect media fetching
| Surface | Values | Effect on the network |
|---|---|---|
preload |
none | metadata | auto |
Advisory target readyState; invalid and missing defaults are implementation-defined |
autoplay |
boolean | Overrides preload; the element must buffer regardless of the hint |
poster |
URL | A separate image request at image priority — the usual LCP candidate for a hero video |
src vs <source> |
URL / list | Changing src after load re-runs resource selection and can duplicate the whole fetch |
crossorigin |
anonymous | use-credentials |
Sets the media request’s CORS mode; must match any preload of the same URL |
HTMLMediaElement.load() |
method | Aborts in-flight ranges and restarts the resource selection algorithm |
networkState |
0–3 | NETWORK_IDLE after suspend; NETWORK_LOADING while ranges are in flight |
readyState |
0–4 | The state each preload keyword targets |
buffered |
TimeRanges |
The decoded ranges held — the ground truth for how much was actually fetched |
Range / Content-Range |
HTTP | The request/response pair every media fetch is built on |
Accept-Ranges: bytes |
HTTP | Required by WebKit; without it Safari will not play the resource |
Browser support matrix
| Capability | Chrome / Edge | Safari | Firefox |
|---|---|---|---|
preload="none" respected |
Yes | Yes | Yes |
preload="metadata" respected |
Yes | Yes (macOS); iOS defers to gesture | Yes |
preload="auto" respected |
Yes, bounded by suspend heuristic |
macOS yes; iOS clamps | Yes, bounded by readahead limit |
| Byte-range media loading | Yes | Yes, required | Yes |
<link rel="preload" as="video"> |
Hint dropped, console warning | Not honoured | Not honoured |
<link rel="preload" as="fetch"> for manifests |
Yes (needs crossorigin) |
Yes (needs crossorigin) |
Yes (needs crossorigin) |
| Video first frame as an LCP candidate | Yes (Chrome 112+) | Not reported | Not reported |
| Managed Media Source | Behind flags | iOS 17.1+ | No |
navigator.connection.saveData gating |
Yes | Not exposed | Not exposed |
The row that surprises people is the as="video" one. Both video and audio are legal request destinations in the Fetch standard, so the markup validates and the attribute reads as though it should work. Chromium’s preload implementation, however, does not create a media request for those destinations and drops the hint with a console warning. Even in a world where the fetch went through, the preload cache would hold a full-body 200 response while the media element asks for bytes=0- — a partial-content request that a full-body cache entry cannot satisfy — so you would pay for the file twice and still collect a “preloaded but not used” warning. Preload the poster or the manifest; never the media body.
Step-by-step implementation
Step 1 — Inventory and classify every media element
Before changing an attribute, write down for each <video> and <audio> on the template: is it above the fold, can it become the LCP element, is it progressive or adaptive, and does anything autoplay. The classification determines the strategy, and the branch below is the whole decision in one picture.
Step 2 — Make preload="none" the default for everything that is not the hero
<!-- Scheduling rationale: the element is below the fold, so no media byte it
fetches can affect first paint — but every byte it does fetch is taken
from the LCP image's share of the downlink. none defers the entire
range conversation until the user commits by pressing play. -->
<video preload="none"
poster="/img/demo-poster-960.webp"
width="960" height="540"
controls playsinline>
<source src="/media/demo-av1.mp4" type="video/mp4; codecs=av01.0.05M.08">
<source src="/media/demo-h264.mp4" type="video/mp4; codecs=avc1.640028">
</video>
Order the <source> list most-efficient-first: the browser picks the first entry whose type it can play, and it makes that choice from the type string alone — no request is issued for a candidate it rejects. Getting the codecs parameter right therefore saves a whole fetch on engines that do not support the newer codec.
Step 3 — Fix the container and the origin before touching the hint
# Scheduling rationale: moving moov ahead of mdat removes an entire
# request/response round trip from the path to HAVE_METADATA — the browser
# can parse the sample tables out of the first range it already asked for,
# instead of discovering it needs the tail of a 12 MB file.
ffmpeg -i source.mov -c copy -movflags +faststart demo-h264.mp4
# Verify the origin answers a slice with 206 rather than the whole body.
curl -sI -H 'Range: bytes=0-1023' https://example.com/media/demo-h264.mp4
# Expect: HTTP/2 206 · Accept-Ranges: bytes · Content-Range: bytes 0-1023/12582912
If that curl prints 200, stop and fix the origin. Every strategy on this page assumes range support; without it preload="metadata" is a full download in disguise and Safari will not play the file at all.
Step 4 — Preload the poster, not the video
When a hero video is the first thing in the viewport, the element the browser measures for LCP is almost always the poster image. Treat it exactly like any other above-the-fold asset:
<!-- Scheduling rationale: the poster is the LCP candidate, so it needs the
high image band from discovery rather than the post-layout boost. The
video element stays on metadata: one short range fetch buys the correct
intrinsic size and duration without competing for the downlink. -->
<link rel="preload" as="image" href="/img/hero-poster-1280.webp"
type="image/webp" fetchpriority="high">
<video preload="metadata"
poster="/img/hero-poster-1280.webp"
width="1280" height="720"
muted playsinline controls>
<source src="/media/hero-h264.mp4" type="video/mp4; codecs=avc1.640028">
</video>
Note the asymmetry: the poster gets a hint, the media file does not. That is deliberate — preload="metadata" already schedules the only media bytes worth fetching before intent, and there is no supported hint that would usefully accelerate them.
Step 5 — For adaptive streams, warm the manifest and the init segment
An HLS or DASH player cannot start until it has parsed a manifest and, for DASH, an initialization segment. Those are small, cacheable, and on the critical path to first frame — which makes them the one part of a media stack that genuinely benefits from a resource hint:
<!-- Scheduling rationale: the player's first fetch cannot begin until the
TLS handshake with the media origin completes, and its second cannot
begin until the manifest has been parsed. preconnect removes the
handshake from the critical path; preloading the manifest and the init
segment removes one serial round trip each from time-to-first-frame. -->
<link rel="preconnect" href="https://media.example.com" crossorigin>
<link rel="preload" as="fetch" crossorigin
href="https://media.example.com/vod/4211/master.m3u8">
<link rel="preload" as="fetch" crossorigin
href="https://media.example.com/vod/4211/720p/init.mp4">
The crossorigin attribute is mandatory here even for same-origin manifests. A preload with as="fetch" and no crossorigin is issued in a different mode from the fetch() the player will make, the two requests do not match in the preload cache, and the browser downloads the manifest twice. Use bare crossorigin (equivalent to anonymous) when the player fetches without credentials, and crossorigin="use-credentials" when it sends cookies — the modes must agree exactly. Segment-level warming beyond the init segment is a separate discipline covered in preloading HLS and DASH manifest segments.
Step 6 — Escalate on intent, not on arrival
The cheapest upgrade path is to leave everything cold and promote a single element when the user shows interest. Hover, focus and viewport proximity are all reasonable triggers, and all of them are cheaper than guessing at parse time:
// Scheduling rationale: escalating on pointerenter buys roughly the 200-400 ms
// between hover and click for the metadata fetch, while keeping the load
// itself at zero media bytes. Setting the property before calling load() is
// what makes the escalation take effect — load() re-runs resource selection
// and reads the current preload value.
const video = document.querySelector('#demo');
const warm = () => {
if (navigator.connection?.saveData) return; // never spend a saver's data speculatively
if (video.readyState >= HTMLMediaElement.HAVE_METADATA) return;
video.preload = 'metadata';
video.load(); // starts the header range fetch
};
video.addEventListener('pointerenter', warm, { once: true });
video.addEventListener('focusin', warm, { once: true });
For third-party players the same idea is best expressed as a facade — a poster and a play button that only instantiate the embed on click, as covered in replacing third-party embeds with facades. A facade removes an entire third-party origin from the initial load, which is usually worth more than any attribute you could set on the iframe.
Verification workflow
In the Network panel
- Open DevTools → Network, enable the Priority column, and apply a Fast 4G or slower profile. Media contention is invisible on an uncontended link, so an unthrottled check will always tell you everything is fine.
- Click the Media filter chip. On a correctly configured page with
preload="none"this list is empty until you press play — that is the assertion to make in review. - Select the first media row and read the Headers pane. The request must carry
Range: bytes=0-, the response must be206 Partial ContentwithContent-RangeandAccept-Ranges: bytes. A200here is the bug, whatever else the page is doing. - Count the rows for one
src. With faststart you should see one range beforeloadedmetadata; two or three means themoovatom is still at the end of the file. - Compare the hero image’s Duration with and without the video present. A hero that takes twice as long with media on the page is contention, not a slow CDN — the timings breakdown in the network waterfall will show the extra time in Content Download, not in Waiting.
With Resource Timing
Media entries appear in Resource Timing with initiatorType of video or audio, one entry per range request, all sharing the same name. Summing encodedBodySize across them is the only honest measure of what a page actually spent on media:
// Verification rationale: the per-keyword byte budget is the thing that
// regresses silently — a CMS field flipped to autoplay, or a component
// default of preload="auto", shows up here long before it shows up as an
// LCP alert. Aggregate by name because each range is a separate entry.
const mediaBytes = new Map();
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (e.initiatorType !== 'video' && e.initiatorType !== 'audio') continue;
const prev = mediaBytes.get(e.name) ?? { ranges: 0, bytes: 0 };
mediaBytes.set(e.name, {
ranges: prev.ranges + 1,
bytes: prev.bytes + e.encodedBodySize // 0 for a cache hit; check transferSize too
});
}
console.table(Object.fromEntries(mediaBytes));
}).observe({ type: 'resource', buffered: true });
Run this at the load event on a cold cache and assert a budget: for a page whose media is all below the fold, the correct number of ranges before first interaction is zero.
Instrumenting the element itself
// Verification rationale: readyState is the ground truth for how far the
// preload hint actually got. suspend firing at readyState 1 confirms
// metadata behaviour; suspend at 4 means the browser buffered to
// HAVE_ENOUGH_DATA and your "metadata" hint was overridden somewhere.
for (const ev of ['loadstart', 'loadedmetadata', 'progress', 'suspend', 'stalled']) {
video.addEventListener(ev, () => {
console.log(ev, {
networkState: video.networkState,
readyState: video.readyState,
buffered: video.buffered.length ? video.buffered.end(0).toFixed(2) : 0
});
});
}
A suspend at readyState 1 with a fraction of a second buffered is metadata working correctly. A suspend at 4 with thirty seconds buffered means something — usually an autoplay attribute, sometimes a player library calling load() — overrode the hint.
Edge cases and gotchas
autoplay silently outranks everything
The spec is explicit that a media element which will play automatically must buffer, so preload="none" autoplay is a contradiction the browser resolves in favour of autoplay. The same applies to a script calling play() during initialisation. If you find media bytes on the wire that your attributes say should not exist, search the template for autoplay and the bundle for .play() before suspecting the browser.
Safari refuses a 200 to a ranged request
WebKit treats byte-range support as a hard requirement for media. An origin, a proxy, or — most often — a service worker that answers a Range request with a complete 200 body will play fine in Chrome and fail silently in Safari. The service worker case is particularly nasty because it appears only after the worker installs, so the first visit works and the second does not:
// Scheduling rationale: a cached full-body response cannot answer a partial
// request, and synthesising a 206 by slicing the body defeats streaming.
// Letting ranged media requests fall through to the network keeps the
// browser's own sparse-cache handling in charge of media bytes.
self.addEventListener('fetch', (event) => {
if (event.request.headers.has('range')) return; // network handles media ranges
if (event.request.destination === 'video') return;
event.respondWith(cacheFirst(event.request));
});
preload="metadata" is not a fixed byte count
How much a browser reads to satisfy “metadata” depends on the container, not on a constant. A faststart MP4 needs the first tens of kilobytes; a WebM with a late Cues element needs the tail; a fragmented MP4 needs the init segment. Measure the actual figure per asset rather than budgeting from a rule of thumb — the matrix at the top of this page reports ~96 KB for one specific file, not a universal number.
Changing src restarts everything
Assigning a new src, or mutating the <source> list and calling load(), aborts every in-flight range and re-runs the resource selection algorithm from the beginning. Responsive video implementations that swap sources on resize can therefore download the same content twice on a single page view. Where you need resolution switching, use the media attribute on <source> (evaluated once, at selection time) or move to an adaptive stream, rather than reassigning src from a resize handler.
Media requests do not respect fetchpriority
fetchpriority is defined for <img>, <link>, <script> and <iframe> — not for <video> or <audio>. There is no attribute that raises or lowers a media transfer’s band, which is precisely why the levers in this page are all about whether and when bytes are requested rather than in what order. The fetchpriority reference covers the surfaces where the attribute does apply, including the poster image.
Cache headers on segments matter more than on the manifest
For adaptive streams the manifest is small and often short-lived, while segments are large and immutable. Serving segments with a long max-age and an immutable revision in the path is what makes a second view cheap; serving the manifest with a long max-age is what makes a live stream stop updating. Get the two backwards and you have a player that re-downloads gigabytes while showing a stale rendition list.
Deferring the element is not the same as deferring the fetch
loading="lazy" does not exist for <video>. Wrapping a video in a container with content-visibility: auto skips its rendering work but does not stop the media loader, and an IntersectionObserver that swaps in a src on viewport entry is the closest equivalent — with all the LCP caveats described under lazy loading and viewport-driven fetching. For media, preload="none" is the deferral primitive; viewport observation is the escalation trigger on top of it.
FAQ
Can I use link rel=preload with as="video"?
In practice, no. video and audio are legal request destinations, so the markup validates, but Chromium’s preload implementation does not create a media request for them and drops the hint with a console warning. Even if the fetch succeeded, the preload cache would hold a full-body 200 while the media element asks for bytes=0-; a complete response cannot satisfy a partial-content request, so you would pay for the bytes twice and still see the preloaded-but-not-used warning. Preload the poster or the manifest instead.
Does preload="metadata" really only download the header?
Only when the container cooperates and the origin honours ranges. A progressive MP4 whose moov atom sits at the end forces the browser to fetch the head, then the tail, then the head again — three round trips for what should be one. And a server that answers 200 instead of 206 leaves the browser with no way to ask for a slice, so “metadata” becomes a full download. Remux with faststart and confirm Accept-Ranges: bytes before you trust the keyword.
Why does my video play in Chrome but not in Safari?
Nine times out of ten, something in the response path is answering a ranged request with a full 200. Chromium falls back to progressive streaming and appears to work; WebKit treats it as a protocol failure and shows nothing. The usual offender is a service worker fetch handler that serves from cache without inspecting the Range header — which is why the symptom often appears on the second visit rather than the first.
Should a hero video use preload="auto" to start playback faster?
Almost never on a page with a meaningful LCP. auto buffers megabytes before the user has decided to watch anything, and on a constrained downlink those bytes come straight out of the resource that paints your largest element. Preload the poster with fetchpriority="high", keep the element on metadata, and escalate to auto only after the poster has painted or the user has hovered, focused or pressed play. The trade-off between the two keywords is worked through in detail in choosing video preload metadata vs auto.
How do I preload an HLS or DASH stream?
Preload the manifest and the initialization segment — never the media segments. Add a preconnect to the media origin so the handshake is off the critical path, then a <link rel="preload" as="fetch" crossorigin> for the manifest and the init segment, with a crossorigin mode that matches how the player fetches. Everything past the init segment belongs to the player’s adaptive logic, which knows the measured throughput and the current buffer level and will make better decisions than a static hint can.
Related
- Preloading HLS and DASH Manifest Segments — manifest, init segment and first-segment warming for adaptive players
- Choosing video preload=“metadata” vs “auto” — the keyword trade-off measured against startup time and LCP
- Strategic Preconnect & DNS-Prefetch Usage — removing the media origin’s handshake from the critical path
- Mastering Link Rel Preload & Prefetch — the
asvalues, CORS matching rules and reuse semantics behind every hint here - Up: Resource Hint Implementation & Preloading Strategies