Preloading HLS and DASH Manifest Segments

Your poster image paints in about a second, then the video sits there for another 300–500 ms before the first frame appears — because the adaptive player has to walk a chain of four dependent requests that the browser’s preload scanner never had a chance to see.

Root cause: a dependency chain no scanner can see

The preload scanner is a second, speculative HTML tokenizer. It finds URLs in markup — src, href, imagesrcset — and dispatches them ahead of the parser. An adaptive stream has no markup. For HLS through Media Source Extensions, or for any DASH playback, the only thing on the page is an empty <video> element and a script tag; the manifest URL is a string inside a JavaScript configuration object. The scanner reads the script tag, fetches the bundle, and learns nothing about the media origin at all.

That would cost one round trip if the manifest were the only thing needed. It is not. HLS, as specified in RFC 8216, splits the description across two documents: the multivariant playlist enumerates renditions with #EXT-X-STREAM-INF lines, and each of those names a media playlist URI. The media playlist is where #EXT-X-MAP names the initialization segment and where the #EXTINF entries name the media segments. DASH (ISO/IEC 23009-1) packs everything into one MPD, so it needs one document instead of two, but the SegmentTemplate@initialization URL is still only discoverable once that MPD has been parsed. In both cases the URL of each request lives inside the body of the previous response. Nothing in the chain can be parallelised by a browser that cannot see the graph.

On top of that sits connection setup. The media host is almost always a separate origin from the document, so the player’s first fetch pays DNS, TCP and TLS before it pays anything else — three round trips on a cold connection, and they are only spent when the bundle finally calls loadSource(). The chain below is a real cold start against a CDN 60 ms away.

The seven serial dependencies between navigation and an HLS first frame A vertical chain of seven paired cards. The left card of each pair names a request and its timing; the right card explains why it cannot begin earlier. The chain runs document, player bundle, connection setup, multivariant playlist, media playlist, initialization segment and first media segment, ending with a first frame at 1240 milliseconds. Four of the steps are marked as removable from the critical path. Cold VOD start · cross-origin CDN · 60 ms RTT · HTTP/2 · no resource hints time to first frame: 1240 ms 1 · /index.html t = 0 → 40 ms The document. Everything below waits on it. The preload scanner starts scanning here. 2 · hls.min.js + boot · 142 KB t = 40 → 300 ms Found by the scanner, but the manifest URL is a string in its config — nothing else can start. 3 · DNS + TCP + TLS t = 300 → 480 ms A second origin: three round trips at 60 ms, opened by the player's very first fetch. 4 · master.m3u8 · 1.4 KB t = 480 → 545 ms Multivariant playlist. It names the variant playlists, so its body is what unblocks step 5. 5 · 720p/index.m3u8 · 6 KB t = 545 → 610 ms Media playlist. Carries EXT-X-MAP and the segment list, both unknown 65 ms ago. 6 · 720p/init.mp4 · 1.1 KB t = 610 → 680 ms Initialisation segment. Configures the SourceBuffer before media data can be appended. 7 · 720p/seg-00001.m4s · 1.2 MB t = 680 → 1180 ms First media segment. 500 ms of transfer that could have begun at 305 ms with hints in the head. First frame at 1240 ms · seven serial dependencies, four of them removable

Note what is not the problem here. The player’s own requests are dispatched at a perfectly reasonable priority: fetch() maps to the High band in Chromium, so manifests and segments are not starved behind images. Raising them further would change nothing, because none of them is waiting in a queue. Every one of them is waiting for a URL. This is a discovery problem, not a priority queue problem, and the only tool that fixes discovery is a hint in the document head.

Minimal reproduction

Three files. The markup carries no trace of the media origin:

<!-- Reproduction: nothing in this markup names media.example.com or
     master.m3u8, so the preload scanner cannot dispatch either. The
     scanner sees two scripts and an empty video element with a poster. -->
<video id="player" poster="/img/poster-1280.webp" playsinline controls></video>
<script src="/js/hls.min.js" defer></script>
<script src="/js/player-boot.js" defer></script>
// player-boot.js — the manifest URL exists only as a string at runtime.
// Because both scripts are deferred, this line executes after the parser
// finishes, roughly 300 ms in: that is the moment the media origin is
// first contacted, and every media request queues behind it.
const SRC = 'https://media.example.com/vod/4211/master.m3u8';

const hls = new Hls({ startLevel: 2 });   // pin the startup rendition, see step 8
hls.loadSource(SRC);                      // → DNS + TCP + TLS, then GET master.m3u8
hls.attachMedia(document.querySelector('#player'));

You can walk the same chain by hand and watch each response hand you the next URL:

# Each command's output contains the URL the next command needs. That is
# precisely why the browser cannot overlap them: the dependency edges live
# in the response bodies, not in the markup.
curl -s https://media.example.com/vod/4211/master.m3u8 | grep -A1 EXT-X-STREAM-INF
# #EXT-X-STREAM-INF:BANDWIDTH=2996000,RESOLUTION=1280x720,CODECS="avc1.640020,mp4a.40.2"
# 720p/index.m3u8

curl -s https://media.example.com/vod/4211/720p/index.m3u8 | head -4
# #EXTM3U
# #EXT-X-TARGETDURATION:6
# #EXT-X-MAP:URI="init.mp4"
# #EXTINF:6.000,

Two curl invocations, and you now know every URL the player will need for the first 6 seconds of playback. The build that produced the page knew them too. The only thing missing is telling the browser.

What the preload cache will and will not match

A hint only helps if the player’s own request reuses it. Chromium keys preload cache entries on the URL plus the request mode, the credentials mode and the destination, and the matching is strict. as="fetch" sets the destination to the empty string, which is what fetch() and XMLHttpRequest use — that part is easy. The crossorigin attribute is the part that goes wrong: omit it and the hint is issued in no-cors mode, while the player fetches in cors mode; add a bare crossorigin and the credentials mode is same-origin, while a player configured with credentials: 'include' uses include. Either mismatch downloads the manifest twice and logs a “preloaded but not used” warning a few seconds later.

Which preload hint configurations a player's manifest request will actually reuse A three by three matrix. Columns are three link preload configurations: as=fetch with no crossorigin, with a bare crossorigin, and with crossorigin set to use-credentials. Rows are three ways the manifest is requested: a default fetch, a fetch with credentials include, and Safari's native HLS video element. Only two cells reuse the hint; every other combination downloads the manifest a second time. as="fetch" no crossorigin as="fetch" crossorigin as="fetch" use-credentials fetch(url) credentials: same-origin 2 requests no-cors vs cors 1 request every key matches 2 requests credentials differ fetch(url, { credentials: 'include' }) 2 requests no-cors vs cors 2 requests credentials differ 1 request every key matches <video src=master.m3u8> Safari native HLS 2 requests media loader, not fetch 2 requests media loader, not fetch 2 requests media loader, not fetch Chromium keys the preload cache on URL, request mode, credentials mode and destination — all four must match. A mismatch is not an error — it is a silent second download of the same bytes. Safari's native HLS loader never consults the preload cache; warm that path with preconnect only.

The bottom row is the one that catches teams shipping a single codebase across engines. When Safari plays HLS natively from <video src="…m3u8">, the manifest is fetched by the media element’s own loader — the same loader that issues the byte-range requests described on the parent topic — and that loader does not consult the preload cache at all. On that path the only hint worth shipping is a preconnect, which still removes 180 ms of handshake. Everything else is dead weight, so gate the preload tags on the branch that actually uses Media Source Extensions.

Deterministic fix protocol

  • [ ] 1. Confirm the chain is serial before optimising it. Record with a slow profile and the Priority column enabled. You are looking for two signatures: the manifest request starting only after the player bundle’s Finish time, and each subsequent media request starting within ~5 ms of the previous response completing. If instead you see gaps and Queueing time, you have a different problem — read network waterfall anatomy first.

  • [ ] 2. Preconnect to every media origin. Manifest host, segment host and DRM licence host each need their own hint. See strategic preconnect usage for why crossorigin is required here and why more than four preconnects is usually counterproductive.

  • [ ] 3. Preload the manifest with a matching CORS mode. as="fetch" plus a crossorigin value that mirrors the player’s fetch exactly — bare crossorigin for a default fetch(), crossorigin="use-credentials" when the player sends cookies.

  • [ ] 4. Preload the startup variant playlist (HLS only). This step needs deterministic ladder paths from your packager. DASH skips it: SegmentTemplate lives inside the MPD, so one document covers what HLS needs two for.

  • [ ] 5. Preload the initialization segment. One or two kilobytes, and nothing can be decoded until it has been appended to the SourceBuffer.

  • [ ] 6. Emit the hints from the template that knows the asset ID. Server-rendered <link> tags in the head, or a 103 Early Hints response if your origin’s time-to-first-byte is large enough to be worth attacking.

  • [ ] 7. Stop at the init segment unless autoplay is certain. A media segment is hundreds of kilobytes committed to one bitrate before any throughput measurement exists.

  • [ ] 8. Pin the startup rendition. If the player’s adaptive logic selects a different variant from the one you preloaded, both hints are wasted. startLevel in hls.js, initialBitrate in Shaka, initialRepresentationRatio in dash.js.

  • [ ] 9. Align cache headers with the object’s lifetime. VOD manifests: max-age=60. Segments: max-age=31536000, immutable behind a versioned path. Live media playlists: never a long max-age, or the preload pins a stale segment list and the player starts behind the live edge.

  • [ ] 10. Verify reuse, not just presence. Every preloaded URL must appear once with transferSize: 0 on the player’s fetch, and no console warning may mention an unused preload.

The whole fix for HLS is four tags:

<!-- Scheduling rationale: these sit in the document head, so the preload
     scanner dispatches them at ~20 ms — about 280 ms before player-boot.js
     runs. The three-request manifest chain then resolves in parallel with
     the bundle download rather than after it. crossorigin is not optional:
     it is part of the preload cache key and hls.js fetches in CORS mode. -->
<link rel="preconnect" href="https://media.example.com" crossorigin>
<link rel="preload" as="fetch" crossorigin fetchpriority="high"
      href="https://media.example.com/vod/4211/master.m3u8">
<link rel="preload" as="fetch" crossorigin
      href="https://media.example.com/vod/4211/720p/index.m3u8">
<link rel="preload" as="fetch" crossorigin
      href="https://media.example.com/vod/4211/720p/init.mp4">

DASH needs one fewer, because the MPD carries what HLS splits across two playlists:

<!-- Scheduling rationale: SegmentTemplate@initialization is inside the MPD,
     so the init segment URL is known after a single response — but the MPD
     itself is still invisible to the scanner. Preload the MPD and the init
     segment of the startup Representation only; the media template expands
     to hundreds of URLs and warming any of them pre-empts the player's own
     throughput estimate. -->
<link rel="preconnect" href="https://media.example.com" crossorigin>
<link rel="preload" as="fetch" crossorigin fetchpriority="high"
      href="https://media.example.com/vod/4211/manifest.mpd">
<link rel="preload" as="fetch" crossorigin
      href="https://media.example.com/vod/4211/v-720p/init.mp4">

Then prove the player reused them rather than re-fetched them:

// Verification rationale: a matched preload is served out of the preload
// cache, so the player's own request reports transferSize 0 while
// encodedBodySize stays non-zero. The same URL appearing twice with
// transferSize > 0 is the exact signature the matrix above predicts —
// a crossorigin or credentials mismatch, not a slow CDN.
performance.getEntriesByType('resource')
  .filter(e => /\.(m3u8|mpd|m4s|mp4)(\?|$)/.test(e.name))
  .forEach(e => console.log(
    e.name.split('/').pop(),
    e.initiatorType,              // 'link' = the hint, 'fetch'/'xmlhttprequest' = the player
    Math.round(e.startTime) + 'ms',
    'transfer=' + e.transferSize, // 0 ⇒ reused from the preload cache
    'body=' + e.encodedBodySize
  ));

Before and after

Same page, same 12-minute VOD asset, same 5 Mbps profile with a 60 ms RTT to the media CDN. The only change is the four tags above.

HLS time to first frame before and after preconnect plus manifest and init preloads Two stacked request waterfalls on a 1400 millisecond axis. In the first, the player bundle finishes at 300 milliseconds, connection setup runs to 480, the two playlists to 610, the init segment to 680 and the first media segment to 1180, giving a first frame at 1240 milliseconds. In the second, preconnect and the three preloads run in parallel with the bundle, the first media segment starts at 305 and finishes at 805, and the first frame arrives at 865 milliseconds for 8.5 kilobytes of speculative traffic. Before · no hints · the manifest chain begins only after player.js executes player.js · 142 KB connect · media origin master + media playlist init.mp4 · EXT-X-MAP seg-00001 · 1.2 MB first frame 1240 ms 0 ms 200 400 600 800 1000 1200 1400 After · preconnect + manifest and init preload · the chain runs beside the bundle preconnect · media playlists · preloaded init.mp4 · preloaded player.js · 142 KB seg-00001 · 1.2 MB first frame 865 ms Time to first frame 1240 ms → 865 ms 8.5 KB spent speculatively 0 ms 200 400 600 800 1000 1200 1400

The third column below is the aggressive variant from step 7 — the same hints plus the first media segment of the pinned 720p rendition. It is 160 ms faster again and it costs 420 KB that a user who never presses play will never use, which is why it belongs only on pages where the video autoplays.

Milestone · cold cache, 60 ms RTT, HTTP/2, 5 Mbps No hints Preconnect + 3 preloads Also first segment
Media origin connection ready 480 ms 195 ms 195 ms
Multivariant playlist complete 545 ms 260 ms 260 ms
Media playlist complete 610 ms 262 ms 262 ms
Init segment complete 680 ms 275 ms 275 ms
First media segment complete 1180 ms 805 ms 645 ms
Time to first frame 1240 ms 865 ms 705 ms
Serial round trips before the first media byte 6 2 1
Speculative bytes if the user never plays 0 KB 8.5 KB 428 KB
Poster LCP 1.05 s 1.06 s 1.28 s

Two rows deserve attention. The round-trip count is the number that actually moved — the hints did not make anything download faster, they collapsed a chain of six dependent exchanges into two. And the poster LCP row shows the cost of overreaching: eight kilobytes of manifest is free, while 420 KB of speculative segment takes 230 ms out of the image that defines your Largest Contentful Paint. The same trade-off, expressed through the preload attribute rather than through link hints, is worked through in choosing video preload metadata vs auto.

FAQ

Can I preload the media segments themselves, not just the init segment?

Only under two conditions: the video autoplays, and the startup rendition is pinned so the segment you preloaded is the one the player asks for. A preloaded media segment commits hundreds of kilobytes to a single bitrate before the player has measured any throughput, and if adaptive logic picks a different rendition those bytes are pure waste taken from your poster image’s share of the downlink. Preloading anything past the first segment is always wrong: segment two onward is selected from a throughput estimate that does not exist at document-head time. Note too that a segment fetched with #EXT-X-BYTERANGE will be requested by the player with a Range header, and a full-body 200 in the preload cache cannot satisfy a partial-content request.

Why does my preloaded manifest still show two requests in the Network panel?

The preload cache key includes the request mode, the credentials mode and the destination alongside the URL, and all four must match. The three usual causes are a missing crossorigin attribute (hint goes out no-cors, player fetches cors), a credentials mismatch (bare crossorigin against a player configured with credentials: 'include'), and a URL that is not byte-identical because the player appends a session token to a signed URL. The last one is the sneakiest: generate the hint’s href from the same signing helper the player uses, never from a hand-written string.

They share a word and nothing else. #EXT-X-PRELOAD-HINT is a Low-Latency HLS playlist tag that names a partial segment which does not exist yet; the player requests it and the origin holds the response open until the encoder produces the bytes. It is a server-to-player mechanism carried inside the playlist body, and the browser’s preload scanner never sees it. <link rel="preload"> is a document-level hint for resources whose URLs are already known at render time. Use link preload for the manifest and the init segment, and leave #EXT-X-PRELOAD-HINT entirely to the packager and the player.


Related