Choosing video preload=“metadata” vs “auto”
Your hero video reaches first frame about 400 ms faster with preload="auto" than with preload="metadata", and the 89 % of visitors who never press play pay 3.4 MB and 1.55 s of Largest Contentful Paint for a saving they will never collect.
Root cause: the keyword moves one number, the buffering high-water mark
Both keywords run the same machinery. The media element’s resource selection algorithm picks a source, the resource fetch algorithm opens a Range: bytes=0- request, and the loader parses whatever comes back until it can raise readyState. The preload attribute does not change any of that. What it changes is the point at which the loader decides it has fetched enough and calls its internal stop-loading path — the high-water mark. Everything else you observe, including the extra second and a half of LCP, is a consequence of that single threshold.
Under metadata the mark sits at the metadata boundary. The loader climbs to HAVE_METADATA (readyState 1), fires loadedmetadata, aborts the in-flight range and drops networkState to NETWORK_IDLE with a suspend event. On a faststart MP4 that is one request and roughly 96 KB. The abort is worth understanding at the protocol level: the loader does not politely finish the response, it cancels the stream — RST_STREAM with error code CANCEL on HTTP/2, STOP_SENDING on HTTP/3 — which means any bytes already in flight inside the receiver’s window still arrive and still cost you. On a high bandwidth-delay-product path, “96 KB” is really 96 KB plus one window’s worth of overshoot.
Under auto the mark moves to the buffering target: keep fetching until the decoded buffer holds n seconds of media ahead of the playhead, subject to an absolute byte ceiling. Chromium’s target is on the order of ten to thirty seconds depending on the measured bitrate; Firefox exposes it directly as media.cache_readahead_limit, 60 seconds by default, with media.cache_resume_threshold at 30 seconds controlling when fetching restarts. That pairing is the important part: auto is not a single download, it is a sawtooth. The loader fills to the high-water mark, suspends, lets playback drain the buffer to the low-water mark, resumes with a fresh range request, and repeats for the length of the asset. Media transfers sit in the Low band of the browser’s priority queues, but a Low-band stream that has already been granted flow-control window keeps consuming it, so each refill competes for the same congestion window as everything else on the page.
The third mechanism worth naming is what happens to the rest of the page while the sawtooth runs. A video transfer is long-lived, so it accumulates a share of the connection’s capacity far out of proportion to its Low band. Under HTTP/2 that shows up as a stalled hero image; under HTTP/3 per-stream loss recovery removes the head-of-line blocking but not the bandwidth contention, because QUIC still keeps one congestion window per connection. There is no attribute that fixes this: fetchpriority is not defined for <video>, so the only lever you have is whether the bytes are requested at all.
Minimal reproduction
Two elements, identical in every respect except the keyword, plus a probe that reports what each one actually spent. Load it on a cold cache with a 5 Mbps profile and read the console.
<!-- Scheduling rationale: the only difference between these two elements is the
high-water mark the media loader stops at. Both open the same first range
request; a.mp4 cancels it at loadedmetadata, b.mp4 keeps refilling until the
buffering target is met. Identical poster, dimensions and codecs keep the
comparison honest — a different poster would change the LCP candidate. -->
<video id="a" preload="metadata" poster="/img/demo-poster-1280.webp"
width="1280" height="720" controls playsinline>
<source src="/media/demo-h264.mp4" type="video/mp4; codecs=avc1.640028">
</video>
<video id="b" preload="auto" poster="/img/demo-poster-1280.webp"
width="1280" height="720" controls playsinline>
<source src="/media/demo-h264.mp4?variant=auto" type="video/mp4; codecs=avc1.640028">
</video>
The query-string variant on the second source matters: without it the two elements share a URL, the sparse cache entry populated by one satisfies the other, and the measurement collapses. Now log the suspend point for each:
// Measurement rationale: suspend is the event that marks the high-water mark
// being reached — it is the exact instant the loader stopped fetching. Reading
// readyState and buffered.end(0) there gives you the two numbers the keyword
// controls, and performance.now() gives you what the extra bytes cost in time.
for (const id of ['a', 'b']) {
const v = document.getElementById(id);
v.addEventListener('suspend', () => {
const bytes = performance.getEntriesByType('resource')
.filter((e) => e.name === v.currentSrc) // one entry per range request
.reduce((sum, e) => sum + e.encodedBodySize, 0);
console.log(id, {
readyState: v.readyState, // 1 for metadata, 4 for auto
bufferedAhead: v.buffered.length ? +v.buffered.end(0).toFixed(1) : 0,
ranges: performance.getEntriesByType('resource')
.filter((e) => e.name === v.currentSrc).length,
bytes,
atMs: Math.round(performance.now())
});
}, { once: true });
}
Before you trust either number, confirm the origin actually serves byte ranges. Without them metadata degrades into a full download and the comparison is meaningless:
# Scheduling rationale: the metadata high-water mark can only be enforced if the
# loader is able to stop early, and it can only stop early if the response was a
# 206 it is allowed to cancel. A 200 here means the browser has no way to ask for
# a slice, so "metadata" silently becomes "download the whole file".
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
What the difference looks like over a minute
Plot the buffer level rather than the byte counter and the trade becomes obvious. Both keywords converge within a few seconds of the click: once playback starts, metadata opens a range request, fills at roughly 4.7× realtime on this profile and reaches the same high-water mark auto was already sitting on. The startup penalty is real but bounded — about 0.9 s of stall before the first frame on a cold buffer, against 0.5 s for the element that had already buffered. The permanent difference is everything that happened to the left of the click.
Read the two shapes as costs. The copper line spends 3.4 MB in the first 5.4 s of the page’s life, when the network waterfall is at its most crowded and the LCP element is still in flight. The green line spends 96 KB in the same window and defers the rest until a user has told you they want it. After the click the two are indistinguishable, which is the whole argument: auto is not buying a better playback experience, it is buying a slightly earlier start for the minority who play, at the expense of first paint for everyone.
Choosing between them with three measured inputs
Three numbers decide this, and none of them is a matter of taste. The first is the play rate — the share of sessions in which the element is actually started. The second is the median effective downlink of your real traffic, because the damage auto does is contention and contention scales inversely with capacity. The third is whether the media transfer overlaps the LCP element; a video that only begins fetching after the largest paint has landed costs bandwidth but not a Core Web Vital.
Multiply before you decide. If auto saves 400 ms of startup and your play rate is 11 %, the expected saving is 44 ms — set against a measured 1.55 s of LCP regression. That is not a close call. At a 70 % play rate on a 15 Mbps median downlink, with the poster already painted, the same arithmetic runs the other way.
Note what the chart does not contain: a zone in which auto is applied at parse time on a page with a contended critical path. There isn’t one. The top-right zone is auto after the poster has painted, which in practice means a promotion in script rather than an attribute in the markup.
Deterministic fix protocol
- [ ] 1. Measure the play rate before touching the markup. Count
playevents against element impressions for a week. If you cannot produce this number, the answer ismetadata— a guessed play rate is always guessed high. - [ ] 2. Record what
autoactually costs on this asset. Load withpreload="auto"on a cold cache, wait forsuspend, and sumencodedBodySizeacross every Resource Timing entry matchingcurrentSrc. Note the wall-clock time of the suspend event too; that is how long the downlink was shared. - [ ] 3. Confirm range support and container layout.
curl -sI -H 'Range: bytes=0-1023'must return206withAccept-Ranges: bytes, andffmpeg -i in.mov -c copy -movflags +faststart out.mp4must have run. Without both,metadatacosts three round trips instead of one and the comparison is invalid. - [ ] 4. Check for overlap with the LCP element. In the Network panel, compare the media transfer’s start time with the LCP candidate’s
responseEnd. Any overlap disqualifiesautoat parse time regardless of play rate. - [ ] 5. Set
preload="metadata"and preload the poster. The poster is the LCP candidate, so give it<link rel="preload" as="image" fetchpriority="high">exactly as you would any other above-the-fold asset. Never add a preload hint for the media file itself. - [ ] 6. Delete every
autoplayyou did not deliberately choose.autoplayoverridespreloadby specification — the element must buffer regardless of the keyword — so a single stray attribute makes this entire page moot. - [ ] 7. Escalate to
autoon an intent signal. Attachpointerenterandfocusinhandlers that setvideo.preload = 'auto'and callload(), gated onnavigator.connection?.saveDataandeffectiveType. Hover buys 200–400 ms of head start for free. - [ ] 8. Assert the suspend point in review. With
metadata,suspendmust fire atreadyState1 with well under a second buffered. AsuspendatreadyState4 means something — usually a player library callingload()— overrode the hint. - [ ] 9. Ship a byte budget to the field. A
PerformanceObserverthat sums media bytes before first interaction, reported alongside your LCP beacon, catches the CMS field someone flips toautomonths from now.
The escalation in step 7 is the whole strategy in nine lines:
// Scheduling rationale: promoting the element raises the loader's high-water
// mark, but a suspended element will not act on the new value on its own — the
// buffering loop has already stopped. load() restarts resource selection, which
// re-reads preload and reopens the range request from the cached offset.
const video = document.querySelector('#hero');
const escalate = () => {
if (navigator.connection?.saveData) return; // never spend a saver's data
if (navigator.connection?.effectiveType === '2g') return;
video.preload = 'auto'; // raise the high-water mark
video.load(); // reopen the range conversation
};
video.addEventListener('pointerenter', escalate, { once: true });
video.addEventListener('focusin', escalate, { once: true });
Before/after metrics
One template: a hero video with a preloaded poster, an 11 % measured play rate, and a 5 Mbps throttled profile. The only change between the columns is the keyword plus the hover escalation from step 7.
| Metric | preload="auto" |
preload="metadata" + escalate |
How to verify |
|---|---|---|---|
| Media bytes before first interaction | 3.41 MB | 96 KB | Sum of encodedBodySize for initiatorType video |
Range requests before suspend |
9 | 1 | Network panel, Media filter, row count |
readyState at suspend |
4 | 1 | suspend listener logging readyState |
| Downlink held by media | 5.4 s | 0.2 s | responseEnd − startTime summed over media entries |
| LCP (poster image) | 2.85 s | 1.30 s | Lighthouse mobile / LCP PerformanceObserver |
| Time to first frame after click | 0.51 s | 0.93 s cold, 0.58 s after hover | playing event minus click timestamp |
| Expected startup saving × play rate | — | 46 ms forgone | 0.42 s × 0.11 |
| Wasted media bytes per 1,000 sessions | 3.03 GB | 85 MB | Media bytes × sessions that never played |
The row that settles the argument is the last pair. Trading 46 ms of expected startup time for 1.55 s of LCP and 2.95 GB of egress per thousand sessions is not a performance trade-off, it is a defect with a plausible-sounding attribute value. Where a page genuinely needs faster starts, the hover escalation recovers most of the gap at a fraction of the cost.
FAQ
Does preload="auto" download the entire video file?
No. auto means the user agent may download the whole resource, not that it must, and every engine stops at an internal high-water mark. Chromium suspends after buffering roughly ten to thirty seconds of media ahead of the playhead, bounded by an absolute byte ceiling; Firefox stops at media.cache_readahead_limit, 60 seconds by default, and resumes at media.cache_resume_threshold. On the 12 MB, 90-second file used throughout this page that works out to about 3.4 MB before suspend fires — enough to bury an LCP image on a 5 Mbps link, nowhere near the whole file. Budget from a measurement of your own asset, never from the file size.
If auto only costs 400 ms of startup, why not always use it?
Because the saving is collected by the fraction of users who press play, while the cost is paid by everybody. At an 11 % play rate the expected benefit is 0.42 s × 0.11 ≈ 46 ms, against a measured 1.55 s of LCP regression and roughly 3 GB of egress per thousand sessions. The comparison only flips when the play rate is high, the downlink is fast, and the media transfer does not overlap the largest paint — which is the small top-right corner of the zone chart above, and even there the promotion belongs in script after the poster has painted.
Can I switch from metadata to auto after the page has loaded?
Yes, and that combination beats either keyword alone. Assigning video.preload = 'auto' updates the element’s preload state and the buffering loop picks up the higher mark on its next cycle — but if the element has already fired suspend, the loop has stopped, so you must also call load() to re-run resource selection and reopen the range request. Chromium serves the already-fetched header out of its sparse cache entry, so the restart is cheap. Trigger it on real intent (pointerenter, focusin, or an IntersectionObserver with a generous rootMargin), and skip it entirely when navigator.connection.saveData is set.
Related
- Up: Preloading Video & Media Streams — the
preloadspecification, byte ranges and how media competes with the critical path - Preloading HLS and DASH Manifest Segments — the equivalent decision for adaptive streams, where the keyword does not apply