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.

Where metadata and auto stop fetching in the media load state machine A state machine. Three shared states run left to right: NETWORK_EMPTY at readyState 0, NETWORK_LOADING with an open-ended range request, and HAVE_METADATA at 96 kilobytes. The path then splits. The metadata branch cancels the range and suspends at 96 kilobytes with 0.4 seconds buffered. The auto branch keeps buffering to readyState 4 and suspends at 3.4 megabytes with 27 seconds buffered, then loops back to resume fetching whenever the buffer drains below the low-water mark. Shared prefix: both keywords run these three states identically. NETWORK_EMPTY readyState 0 · no bytes NETWORK_LOADING Range: bytes=0- opened HAVE_METADATA readyState 1 · 96 KB preload="metadata" high-water mark reached RST_STREAM cancels range preload="auto" keeps buffering ahead readyState climbs 1 to 4 suspend at 96 KB NETWORK_IDLE · 0.4 s buffered silent until play() is called suspend at 3.4 MB NETWORK_IDLE · 27 s buffered readyState 4 HAVE_ENOUGH resume One 90 s, 12 MB H.264 file, faststart container, cold cache, 5 Mbps downlink, HTTP/2. Only the keyword changed. The suspend/resume loop repeats for the whole asset once playback starts — auto is a sawtooth, not one download.

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.

Buffer level over the first minute for metadata and auto A line chart with time from zero to sixty seconds on the horizontal axis and seconds of media buffered ahead of the playhead on the vertical axis. The auto series climbs to the 27 second high-water mark by 5.4 seconds and holds there while nothing is playing. The metadata series stays at 0.4 seconds. Playback starts at twenty seconds; from there both series sawtooth between the 12 second low-water mark and the 27 second high-water mark, converging within about six seconds. Seconds of media buffered ahead of the playhead — the series converge, the pre-click spend does not 0 10 20 30 0 s 10 s 20 s 30 s 40 s 50 s 60 s high-water: 27 s low-water: 12 s 3.4 MB fetched before the click 96 KB · one range user presses play at t = 20 s preload="auto" preload="metadata" Same file and profile as above; after the click both keywords refill at about 4.7 times realtime and behave identically.

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.

Which keyword is correct, plotted against play rate and downlink A zone chart. The horizontal axis is the share of sessions that press play, from zero to one hundred percent; the vertical axis is median downlink from zero to twenty megabits per second. Below twenty-five percent play rate, preload none with a poster wins at any downlink. Between twenty-five and sixty-five percent, preload metadata is the default. Above sixty-five percent, a slow link still wants metadata with escalation on hover, and only a fast link justifies auto after the poster has painted. The measured example sits at eleven percent and five megabits per second, inside the none zone. Which keyword is correct, plotted against two numbers you can measure median downlink (Mbps) preload="none" poster carries the pixels; 0 media bytes preload="metadata" the default: correct duration and dimensions for 96 KB preload="auto" only once the poster has painted metadata, then escalate on pointerenter or focusin, never on load this page: 11% · 5 Mbps 0 5 10 15 20 0% 25% 50% 75% 100% share of sessions that press play — measured, not estimated Every zone assumes the poster is preloaded and the media transfer does not overlap the LCP element's fetch.

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 play events against element impressions for a week. If you cannot produce this number, the answer is metadata — a guessed play rate is always guessed high.
  • [ ] 2. Record what auto actually costs on this asset. Load with preload="auto" on a cold cache, wait for suspend, and sum encodedBodySize across every Resource Timing entry matching currentSrc. 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 return 206 with Accept-Ranges: bytes, and ffmpeg -i in.mov -c copy -movflags +faststart out.mp4 must have run. Without both, metadata costs 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 disqualifies auto at 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 autoplay you did not deliberately choose. autoplay overrides preload by specification — the element must buffer regardless of the keyword — so a single stray attribute makes this entire page moot.
  • [ ] 7. Escalate to auto on an intent signal. Attach pointerenter and focusin handlers that set video.preload = 'auto' and call load(), gated on navigator.connection?.saveData and effectiveType. Hover buys 200–400 ms of head start for free.
  • [ ] 8. Assert the suspend point in review. With metadata, suspend must fire at readyState 1 with well under a second buffered. A suspend at readyState 4 means something — usually a player library calling load() — overrode the hint.
  • [ ] 9. Ship a byte budget to the field. A PerformanceObserver that sums media bytes before first interaction, reported alongside your LCP beacon, catches the CMS field someone flips to auto months 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