mediacodec.dev

Adaptive bitrate streaming: HLS, DASH and the ladder

How one video becomes dozens of files, why keyframe placement decides whether it works, and what HLS and DASH actually differ on now.

Updated

A single video file cannot serve everyone. Pick a bitrate high enough for a desktop on fibre and you have made something a phone on a train cannot play. Pick one low enough for the train and the desktop gets a blurry picture on a 55-inch screen. Adaptive bitrate streaming resolves this by refusing to choose: you encode the same content several times, cut every version into short segments, and let the player pick a version per segment as conditions change.

Segments and a manifest

Two pieces make it work.

The segments are the media, typically two to six seconds each, encoded at several quality levels. The manifest is a text file listing what exists: which renditions, at what bitrate and resolution, and where the segments live.

The player fetches the manifest, picks a rendition, and starts pulling segments over ordinary HTTP. While it plays, it measures how fast segments are arriving and how full its buffer is. If throughput drops, the next segment request goes to a lower rendition. Nothing is negotiated and no special server is needed, which is the reason this design won: it is files on a CDN.

The ladder

The set of renditions is the ladder. A conventional one for 1080p source:

Rendition Typical H.264 bitrate
1920×1080 5000 kbps
1280×720 3000 kbps
854×480 1500 kbps
640×360 800 kbps
426×240 400 kbps

Each rung is encoded with a bitrate ceiling it must not exceed, which is what capped CRF is for. The rungs are spaced so that each step down is a meaningful drop, roughly 1.5 to 2 times apart. Rungs closer than that just give the player near-identical choices and cost you storage for nothing.

That storage is real: a five-rung ladder is roughly 2.5 times the bytes of the top rendition alone. The storage calculator will price a library, and the bandwidth calculator will price serving it, though note that real sessions average out somewhere below the top rung, so both are upper bounds.

Why keyframes decide whether this works

A player switching rendition mid-playback must be able to start decoding the new rendition at the segment boundary. That is only possible if the segment opens with a keyframe. And for the switch to be seamless, every rendition must have its keyframes in the same places, so segment 47 covers the same span of time in all of them.

This is the constraint that trips up first attempts. If you encode each rendition independently with the encoder choosing keyframe positions by content, the segments will not line up and switching will stutter or fail. The fix is to force a fixed keyframe interval on every rendition:

# 2-second GOP at 30fps, keyframes forced regardless of scene content
ffmpeg -i in.mp4 -c:v libx264 -crf 22 -maxrate 3M -bufsize 6M \
  -g 60 -keyint_min 60 -sc_threshold 0 \
  -c:a aac -b:a 128k out.mp4

-sc_threshold 0 disables scene-change keyframe insertion, which is normally a quality feature and here would break alignment. The trade is real: fixed keyframes cost some efficiency. It buys switchability, and there is more on the underlying mechanics in keyframes and GOP size.

HLS and DASH

HLS came from Apple, uses .m3u8 manifests, and is what iOS has always spoken natively. DASH is the ISO standard, uses .mpd manifests, and grew up on Android and desktop browsers.

The interesting part is how little separates them now. HLS originally carried MPEG-TS segments and DASH carried fragmented MP4, so you genuinely had to encode twice. Since HLS gained fMP4 support, both protocols can point at the same segment files, and CMAF is the specification that pins that down. A modern pipeline encodes one set of fMP4 segments and publishes two manifests over them. Storage barely moves.

The remaining differences are practical rather than technical: HLS has first-class support on Apple devices and in Safari without any JavaScript, while DASH has broader tooling and a more open governance story.

The iOS caveat, and how it aged

For years the rule was simple: iPhones cannot play DASH, because Safari on iOS did not implement Media Source Extensions, and every browser-based DASH player depends on MSE to feed segments to the video element.

Apple changed this with Managed Media Source, which shipped on iPhone in Safari 17.1. It gives a player the same segment-feeding ability as MSE while letting the system manage buffering and network access for battery’s sake, and DASH players have adopted it.

So the hard blocker is gone, but the practical advice has not moved much: if you ship one protocol, ship HLS. It plays natively on Apple hardware with no player library at all, it is supported everywhere else through hls.js, and you avoid depending on how recent your visitors’ iOS is. Ship DASH as well when your tooling makes it nearly free, which with CMAF it usually does.

Sources for the support claims: WebKit Features in Safari 17.1, dash.js Managed Media Source issue.

What to remember

Adaptive streaming is not a codec feature; it is a packaging decision layered on top of ordinary files. Get the keyframes aligned and the rest is bookkeeping. Get them wrong and no amount of player tuning will fix the stutter.