Adaptive Bitrate Streaming with HLS and DASH
Adaptive bitrate streaming is the video counterpart to srcset — instead of the browser picking one file at load time, the player re-picks a rendition every few seconds while playback runs. This guide sits under Responsive Image & Video Delivery, and it covers the parts of the pipeline that responsive markup cannot express: the encoding ladder, the manifest formats that describe it, the segment granularity that governs how fast a player can react, and the bandwidth estimator that decides which rung to fetch next.
The two protocols that matter in a browser are HTTP Live Streaming (HLS, RFC 8216) and MPEG-DASH (ISO/IEC 23009-1). They differ in manifest syntax, in how they address segments, and in which browsers can play them without JavaScript — but since CMAF (ISO/IEC 23000-19) both can be layered over an identical set of fragmented-MP4 segments. That single fact drives most of the architecture decisions on this page: you build one segment set, and you publish two text files that index it.
A working ABR deployment is four artefacts: a ladder of encodes, a set of aligned segments, one or two manifests, and a player policy. Get the alignment wrong and rung switches produce a visible stall; get the ladder spacing wrong and you burn origin storage on renditions nobody ever selects. Both failure modes are cheap to avoid and expensive to retrofit.
Concept & Architecture: The ABR Control Loop
Adaptive streaming is a closed control loop running inside the player. The loop has four stages and it repeats once per segment download.
1. Manifest parse. The player fetches the multivariant playlist (HLS) or the MPD (DASH) and builds an in-memory list of renditions, each with a declared peak bandwidth, resolution, frame rate and codec string. Nothing is downloaded speculatively at this point except, in DASH, the initialization segment for the rendition it intends to start on.
2. Segment request. The player issues a plain GET for the next media segment of the currently selected rung. Segments are ordinary cacheable HTTP objects — there is no streaming protocol on the wire, which is exactly why CDNs handle ABR well and why CDN edge media delivery tuning applies unchanged.
3. Throughput measurement. When the response completes, the player divides transferred bytes by elapsed wall-clock time and folds that sample into an exponentially weighted moving average. Most players keep two averages with different half-lives and take the minimum, so a single fast segment cannot trigger an optimistic upshift.
4. Rung selection. The next segment’s rung is chosen from the throughput estimate, discounted by a safety factor, and cross-checked against buffer occupancy. A player with 4 seconds buffered behaves far more conservatively than the same player with 40 seconds buffered, even at an identical throughput estimate.
The loop only works if every rendition is switchable: the player must be able to append a segment from rung 4 immediately after a segment from rung 1 without flushing the decoder. That requires closed GOPs, identical segment boundaries across every rung, and identical audio timing. The EXT-X-INDEPENDENT-SEGMENTS tag in HLS and @segmentAlignment="true" in DASH are assertions that you have done this — neither packager verifies it for you.
ABR Ladder Design
A ladder is a set of (resolution, bitrate) pairs. The design constraints are simple to state and easy to get wrong:
- Spacing. Consecutive rungs should differ by roughly 1.5× to 2.0× in bitrate. Below 1.5× the visual difference is imperceptible and you have paid storage for nothing; above 2.5× a downshift is a jarring quality cliff and the player spends long stretches on a rung far below the available bandwidth.
- Floor. The lowest rung must be reachable on the worst connection you intend to serve. 200 kbps at 416×234 plays on a congested cellular link where a 600 kbps rung stalls.
- Ceiling. The top rung should not exceed what your audience’s devices can decode in hardware, and it should not exceed the resolution your player element actually occupies. A 4K rung on a 720 px-wide embed is pure waste —
capLevelToPlayerSizein hls.js exists for exactly this. - Resolution coupling. Each bitrate has a resolution above which encoding becomes inefficient. Encoding 1080p at 600 kbps produces a blurrier image than 480p at the same bitrate, because the encoder spends its budget on macroblock count rather than detail.
The reference ladder below is the H.264 baseline that works everywhere. Bitrates are video-only; BANDWIDTH includes audio and the container overhead a manifest must declare.
| Rung | Resolution | fps | Video kbps | maxrate | Audio kbps | BANDWIDTH |
4 s segment |
|---|---|---|---|---|---|---|---|
| 0 | 416×234 | 30 | 200 | 214 | 64 (HE-AAC) | 278 000 | ~132 KB |
| 1 | 640×360 | 30 | 600 | 642 | 96 (AAC-LC) | 738 000 | ~348 KB |
| 2 | 854×480 | 30 | 1 200 | 1 284 | 96 (AAC-LC) | 1 380 000 | ~648 KB |
| 3 | 1280×720 | 30 | 2 400 | 2 568 | 128 (AAC-LC) | 2 696 000 | ~1.24 MB |
| 4 | 1920×1080 | 30 | 4 800 | 5 136 | 128 (AAC-LC) | 5 264 000 | ~2.41 MB |
maxrate is set to 1.07× the average and bufsize to 2× the average, which keeps each rung inside a bounded VBV window. This matters because the ABR estimator compares a measured throughput against the declared BANDWIDTH; if the encoder is allowed to spike to 3× average on a high-motion scene, a rung the player believed was safe will underrun.
Ladders are not universal. A talking-head lecture at 1080p is comfortable at 2 000 kbps where a sports feed needs 6 000 kbps for the same perceived quality, and a mobile-first audience wants more rungs below 1 Mbps and fewer above 3 Mbps. Per-title encoding — running a short convex-hull analysis over test encodes and picking the rungs on the quality/bitrate frontier — typically removes one or two rungs from a fixed ladder at no perceptual cost. For the mobile-specific version of this problem, see choosing ABR rungs for mobile bandwidth.
HLS vs DASH Manifest Structure
Both formats describe the same thing — a set of renditions, each a sequence of segments — with completely different syntax and different addressing philosophies.
HLS uses two levels of plain-text playlists. The multivariant playlist lists renditions as #EXT-X-STREAM-INF tags, each followed by a URI pointing to a media playlist. The media playlist enumerates every segment explicitly with an #EXTINF duration line. Explicit enumeration means a two-hour VOD media playlist contains thousands of lines, but it also means the player never has to compute a URL — it reads one.
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-STREAM-INF:BANDWIDTH=278000,AVERAGE-BANDWIDTH=264000,RESOLUTION=416x234,FRAME-RATE=30.000,CODECS="avc1.42c00d,mp4a.40.5"
stream_0/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1380000,AVERAGE-BANDWIDTH=1296000,RESOLUTION=854x480,FRAME-RATE=30.000,CODECS="avc1.4d401f,mp4a.40.2"
stream_2/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=5264000,AVERAGE-BANDWIDTH=4928000,RESOLUTION=1920x1080,FRAME-RATE=30.000,CODECS="avc1.640028,mp4a.40.2"
stream_4/index.m3u8
# --- stream_2/index.m3u8 (a media playlist) ---
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-MAP:URI="init.mp4"
#EXTINF:4.000,
1.m4s
#EXTINF:4.000,
2.m4s
#EXTINF:3.400,
3.m4s
#EXT-X-ENDLIST
BANDWIDTH must be the peak bitrate over any segment-length window, not the average — players use it as the safety ceiling, and understating it is the single most common cause of mid-stream rebuffering on an otherwise healthy ladder. CODECS must be an exact RFC 6381 string; a wrong profile/level suffix makes MSE reject the SourceBuffer outright.
DASH uses one XML document with templated addressing. The MPD nests Period → AdaptationSet → Representation, and a single SegmentTemplate generates every URL from a pattern. A two-hour presentation is described in a few kilobytes.
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" profiles="urn:mpeg:dash:profile:isoff-live:2011"
type="static" mediaPresentationDuration="PT1H58M12S" minBufferTime="PT8S">
<Period id="0" start="PT0S">
<!-- segmentAlignment asserts that segment N starts at the same presentation
time in every Representation - this is what makes switching seamless. -->
<AdaptationSet id="0" contentType="video" mimeType="video/mp4"
segmentAlignment="true" startWithSAP="1" par="16:9">
<!-- One template serves every Representation in the set. $Number$ starts
at @startNumber; $RepresentationID$ expands to Representation/@id. -->
<SegmentTemplate timescale="90000" duration="360000" startNumber="1"
initialization="$RepresentationID$/init.mp4"
media="$RepresentationID$/$Number$.m4s"/>
<Representation id="stream_0" bandwidth="200000" width="416" height="234" codecs="avc1.42c00d" frameRate="30"/>
<Representation id="stream_2" bandwidth="1200000" width="854" height="480" codecs="avc1.4d401f" frameRate="30"/>
<Representation id="stream_4" bandwidth="4800000" width="1920" height="1080" codecs="avc1.640028" frameRate="30"/>
</AdaptationSet>
<AdaptationSet id="1" contentType="audio" mimeType="audio/mp4" lang="en" segmentAlignment="true">
<SegmentTemplate timescale="48000" duration="192000" startNumber="1"
initialization="audio/init.mp4" media="audio/$Number$.m4s"/>
<Representation id="audio" bandwidth="128000" codecs="mp4a.40.2" audioSamplingRate="48000"/>
</AdaptationSet>
</Period>
</MPD>
Note the structural difference in audio handling. DASH separates audio into its own AdaptationSet, so five video rungs plus one audio rendition is six encodes. Plain HLS #EXT-X-STREAM-INF variants are muxed by default — five variants means five copies of the audio track unless you split it out with #EXT-X-MEDIA:TYPE=AUDIO and an AUDIO="aac" group reference. Demuxed audio is strongly preferred: it cuts storage by roughly 15% on a five-rung ladder and it is a prerequisite for multi-language and multi-codec audio.
The practical consequence of the addressing difference shows up in live streams. A DASH SegmentTemplate with $Number$ and a wall-clock availabilityStartTime lets the player compute the current segment number without refetching the MPD at all; an HLS live media playlist must be re-fetched every target duration. That is why LL-HLS added #EXT-X-PRELOAD-HINT and blocking playlist reloads — it is retrofitting DASH’s ability to request a segment before it exists.
Segment Duration Tradeoffs
Segment duration is the single knob with the widest blast radius. It sets startup latency, live latency, switch granularity, request count, manifest size, and compression efficiency all at once.
| Segment duration | Startup (2 segments) | HLS live latency | Requests/rung, 2 h VOD | VOD media playlist | Switch granularity |
|---|---|---|---|---|---|
| 1 s | ~2 s | ~3 s | 7 200 | ~330 KB | 1 s |
| 2 s | ~4 s | ~6 s | 3 600 | ~165 KB | 2 s |
| 4 s | ~8 s | ~12 s | 1 800 | ~83 KB | 4 s |
| 6 s | ~12 s | ~18 s | 1 200 | ~55 KB | 6 s |
| 10 s | ~20 s | ~30 s | 720 | ~33 KB | 10 s |
Shorter segments react faster but compress worse. Every segment begins with an IDR frame, and IDR frames cost 3–8× an inter frame. Dropping from 6 s to 2 s triples the IDR count and adds roughly 4–8% to the bitrate at fixed quality. Shorter segments also mean more TLS-multiplexed requests: at 1 s, a 2-hour title generates 7 200 requests per rung, which stresses CDN request-rate billing and increases the odds that at least one request lands on a cold edge.
Tradeoff: 4 seconds is the defensible default for VOD. Apple’s HTTP Live Streaming authoring specification recommends 6 seconds, which optimizes for playlist size and CDN efficiency at the cost of a slower first frame; 2 seconds is the right choice when your analytics show high abandonment in the first 5 seconds of playback. Below 2 seconds, use LL-HLS partial segments or DASH SegmentTimeline with chunked-transfer CMAF chunks rather than genuinely shorter segments — that way you keep the compression efficiency of a 4 s GOP while exposing 0.5 s of addressable media.
Whatever you pick, the GOP length must divide the segment duration exactly. A 4 s segment with a 2.5 s keyframe interval forces the packager to cut at a non-IDR boundary or to emit segments of irregular length, and irregular lengths break segmentAlignment across the ladder.
Codec and Container Support, MSE vs Native Playback
There are exactly two ways to play adaptive video in a browser: hand the manifest URL to a <video> element and let the platform do it, or use Media Source Extensions and drive the buffer from JavaScript.
| Path | Where it works | What you gain | What you lose |
|---|---|---|---|
Native HLS (<video src="…m3u8">) |
Safari (macOS, iOS, iPadOS, tvOS), Android Chrome partial | Hardware pipeline, lowest power draw, AirPlay, Picture-in-Picture, FairPlay | No control over ABR, no per-segment auth headers, opaque buffer |
MSE (hls.js, dash.js, shaka-player) |
Chrome 23+, Edge, Firefox 42+, Safari on macOS/iPadOS | Full ABR control, custom retry/auth, Widevine + PlayReady via EME, DASH anywhere | JS transmux cost, buffer quota limits, no AirPlay without a fallback source |
| ManagedMediaSource | Safari 17.1+ including iPhone | MSE semantics on iOS, UA-managed buffer eviction, AirPlay preserved | Buffer size is advisory only; the UA can evict at will |
Native HLS is not available in Chrome or Firefox and never has been. Conversely, MSE was absent from iPhone Safari until ManagedMediaSource shipped in Safari 17.1 — before that, an HLS-only fallback on iPhone was mandatory, not optional. This is why nearly every production player ships both paths and picks at runtime.
Codec availability is the second axis. The codec string you put in CODECS or @codecs must match what the platform can actually decode, and MSE is strict: MediaSource.isTypeSupported() returning false means the SourceBuffer cannot be created at all.
| Container + codec | Safari 17+ (macOS) | Safari iOS 17+ | Chrome 120+ | Firefox 128+ |
|---|---|---|---|---|
fMP4 + H.264 (avc1.*) |
Native + MSE | Native + MMS | MSE | MSE |
fMP4 + HEVC (hvc1.*) |
Yes | Yes | Hardware-dependent | Windows only, hardware-dependent |
fMP4 + AV1 (av01.*) |
Apple silicon / A17 Pro | A17 Pro and newer | Yes (70+) | Yes (67+) |
fMP4 + VP9 (vp09.*) |
No | No | Yes | Yes |
| WebM + VP9 | No | No | Yes | Yes |
| MPEG-2 TS + H.264 | Native HLS | Native HLS | Transmux in hls.js | Transmux in hls.js |
AAC-LC (mp4a.40.2) |
Yes | Yes | Yes | Yes |
H.264 in fMP4 with AAC-LC audio is the only combination with no gaps. Everything above it is an optimization layered on as an additional AdaptationSet or a second #EXT-X-STREAM-INF group — the codec economics behind that choice are covered in understanding video codecs: VP9 vs H.265 vs AV1. Note that MPEG-2 TS segments, the original HLS container, still work but force hls.js to transmux to fMP4 in JavaScript on every segment, costing 3–8 ms of main-thread time per segment on a mid-range phone. Use fMP4 segments and that cost disappears entirely.
A fuller treatment of the browser matrix, including DRM key systems and the fallback ordering that keeps AirPlay alive, lives in DASH vs HLS for browser playback support.
CMAF: One Segment Set, Two Manifests
Before CMAF, supporting both protocols meant encoding twice — MPEG-2 TS for HLS, fragmented MP4 for DASH — with double the storage, double the origin egress, and two independent CDN cache populations that never shared a byte.
CMAF (ISO/IEC 23000-19) standardizes a fragmented-MP4 profile that both HLS 7+ and DASH can index. One init.mp4 plus a sequence of .m4s fragments is referenced by #EXT-X-MAP on the HLS side and by SegmentTemplate/@initialization on the DASH side. The consequences are concrete:
- Storage halves. One set of media bytes instead of two.
- CDN cache hit ratio roughly doubles for the same traffic mix, because Safari traffic and Chrome traffic now warm the same objects. The cache tuning in Cache-Control headers for image and video assets applies directly — segments are immutable and belong on
max-age=31536000, immutable. - One DRM encryption pass. CMAF standardizes
cbcs(AES-128-CBC with pattern encryption), which FairPlay, Widevine and PlayReady all accept. The oldercenc(AES-CTR) mode is not supported by FairPlay, socbcsis the only single-copy option. - Low-latency modes converge. A CMAF chunk is a subset of a fragment containing one
moof/mdatpair; LL-HLS parts and DASH chunked-transfer both address these same chunks.
The cost is that CMAF requires HLS protocol version 7 or higher, which excludes iOS 9 and older Apple TV hardware. On a 2026 audience that is a rounding error, but if your analytics say otherwise you need a parallel TS ladder and you lose the storage saving.
Bandwidth Estimation
The estimator is where players differ most, and where most perceived quality problems originate.
Throughput-based estimation measures each completed segment as transferSize / (responseEnd - requestStart) and feeds an EWMA. hls.js keeps two: abrEwmaFastVoD (3 s half-life) and abrEwmaSlowVoD (9 s half-life), and uses the lower of the two when deciding to upshift. It then applies abrBandWidthUpFactor (default 0.7) before an upshift and abrBandWidthFactor (0.95) before a downshift — asymmetric hysteresis that makes the player reluctant to climb and quick to fall.
The measurement has three systematic biases you must design around:
- Cache hits over-report. A segment served from a warm CDN edge at 90 Mbps tells you nothing about the last-mile link. Estimators partially compensate by weighting samples by transferred bytes, so a fast small segment barely moves the average.
- Slow start under-reports. A 132 KB segment from the lowest rung may complete before TCP leaves slow start, reporting a fraction of the real capacity. This is why players stuck on the bottom rung sometimes never climb — the classic ABR “death spiral”. Setting a non-zero
abrEwmaDefaultEstimateand starting on rung 1 or 2 rather than rung 0 avoids it. - Idle gaps in steady state. Once the buffer is full the player requests one segment every four seconds, so throughput is measured on a duty cycle of maybe 10%. The estimate goes stale exactly when the network changes.
Buffer-based estimation (BOLA, and dash.js’s abrDynamic when the buffer is deep) sidesteps all three by ignoring throughput and selecting purely on buffer occupancy: a full buffer means you can afford to gamble on a higher rung, and the buffer itself absorbs the error if you are wrong. Production players use a hybrid — throughput during startup when there is no buffer to reason about, buffer-based once occupancy exceeds roughly 10 seconds.
Warning: Do not build ABR decisions on navigator.connection.downlink. It is Chromium-only, it reports a rounded, heavily-smoothed estimate capped at 10 Mbps, and it is deliberately coarse for fingerprinting reasons. Segment-level Resource Timing is both more accurate and available everywhere.
Step-by-Step Implementation
Step 1 — Probe the source and confirm the ceiling
Never encode a ladder whose top rung exceeds the source. Upscaling wastes bitrate and produces a rung that scores worse than the one below it.
# Print the properties that constrain ladder design: resolution, frame rate,
# pixel format and duration. r_frame_rate is a rational ("30000/1001"), which
# is what you must feed to the fps filter to avoid frame duplication.
ffprobe -v error -select_streams v:0 \
-show_entries stream=width,height,r_frame_rate,pix_fmt,bit_rate \
-show_entries format=duration \
-of json source.mov
If width comes back as 1280, delete the 1080p rung from the ladder. If pix_fmt is yuv420p10le or similar, add -pix_fmt yuv420p to the encode so the 8-bit H.264 decoders on older devices can play it.
Step 2 — Encode the ladder in a single ffmpeg pass
One pass through the source, five scaled outputs, identical GOP structure across all of them. That last property is what makes the ladder switchable.
#!/usr/bin/env bash
set -euo pipefail
ffmpeg -y -i source.mov \
-filter_complex "\
[0:v]fps=30,split=5[v1][v2][v3][v4][v5]; \
[v1]scale=416:234[v1o];[v2]scale=640:360[v2o];[v3]scale=854:480[v3o]; \
[v4]scale=1280:720[v4o];[v5]scale=1920:1080[v5o]" \
`# --- video: one -map per rung; maxrate is 1.07x average, bufsize 2x ---` \
-map "[v1o]" -c:v:0 libx264 -b:v:0 200k -maxrate:v:0 214k -bufsize:v:0 400k -profile:v:0 baseline -level:v:0 3.0 \
-map "[v2o]" -c:v:1 libx264 -b:v:1 600k -maxrate:v:1 642k -bufsize:v:1 1200k -profile:v:1 main -level:v:1 3.1 \
-map "[v3o]" -c:v:2 libx264 -b:v:2 1200k -maxrate:v:2 1284k -bufsize:v:2 2400k -profile:v:2 main -level:v:2 3.1 \
-map "[v4o]" -c:v:3 libx264 -b:v:3 2400k -maxrate:v:3 2568k -bufsize:v:3 4800k -profile:v:3 high -level:v:3 4.0 \
-map "[v5o]" -c:v:4 libx264 -b:v:4 4800k -maxrate:v:4 5136k -bufsize:v:4 9600k -profile:v:4 high -level:v:4 4.0 \
`# --- audio: same source stream mapped once per variant ---` \
-map a:0 -map a:0 -map a:0 -map a:0 -map a:0 \
-c:a aac -ac 2 -ar 48000 \
-b:a:0 64k -b:a:1 96k -b:a:2 96k -b:a:3 128k -b:a:4 128k \
`# keyint=60 at 30 fps is a closed 2 s GOP; it divides the 4 s segment` \
`# exactly, so every rung cuts on an IDR at the same presentation time.` \
-x264-params "keyint=60:min-keyint=60:scenecut=0:open-gop=0" \
-preset slow -pix_fmt yuv420p -movflags +faststart \
`# --- CMAF fMP4 output, not MPEG-2 TS ---` \
-f hls -hls_time 4 -hls_playlist_type vod \
-hls_segment_type fmp4 -hls_fmp4_init_filename init.mp4 \
-hls_flags independent_segments \
-hls_segment_filename "stream_%v/%d.m4s" \
-master_pl_name master.m3u8 \
-var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2 v:3,a:3 v:4,a:4" \
"stream_%v/index.m3u8"
scenecut=0 and min-keyint=60 together forbid x264 from inserting an IDR at a scene change, which would otherwise desynchronise the GOP grid between rungs — the encoder detects scene cuts at slightly different frames once the picture has been scaled. A step-by-step walkthrough of this command, including per-title variants and two-pass mode, is in building an HLS bitrate ladder with FFmpeg.
Step 3 — Package one CMAF set into both manifests
ffmpeg writes HLS well but is a weak DASH packager. Shaka Packager takes the encoded renditions and emits an HLS multivariant playlist and an MPD over one shared segment set, with demuxed audio.
#!/usr/bin/env bash
set -euo pipefail
packager \
'in=mez_234.mp4,stream=video,init_segment=stream_0/init.mp4,segment_template=stream_0/$Number$.m4s,playlist_name=stream_0/index.m3u8,bandwidth=200000' \
'in=mez_360.mp4,stream=video,init_segment=stream_1/init.mp4,segment_template=stream_1/$Number$.m4s,playlist_name=stream_1/index.m3u8,bandwidth=600000' \
'in=mez_480.mp4,stream=video,init_segment=stream_2/init.mp4,segment_template=stream_2/$Number$.m4s,playlist_name=stream_2/index.m3u8,bandwidth=1200000' \
'in=mez_720.mp4,stream=video,init_segment=stream_3/init.mp4,segment_template=stream_3/$Number$.m4s,playlist_name=stream_3/index.m3u8,bandwidth=2400000' \
'in=mez_1080.mp4,stream=video,init_segment=stream_4/init.mp4,segment_template=stream_4/$Number$.m4s,playlist_name=stream_4/index.m3u8,bandwidth=4800000' \
'in=mez_1080.mp4,stream=audio,init_segment=audio/init.mp4,segment_template=audio/$Number$.m4s,playlist_name=audio/index.m3u8,hls_group_id=aac,hls_name=English' \
--segment_duration 4 \
--fragment_duration 4 \
--generate_static_live_mpd \
--hls_master_playlist_output master.m3u8 \
--mpd_output manifest.mpd
Both master.m3u8 and manifest.mpd now point at the exact same .m4s files. Nothing is duplicated on disk, and a Chrome viewer requesting stream_2/17.m4s via DASH warms the identical CDN object a Safari viewer requests via HLS.
Step 4 — Serve with the right MIME types and cache lifetimes
Manifests and segments have opposite caching profiles: manifests are mutable indexes, segments are immutable content-addressed blobs.
types {
application/vnd.apple.mpegurl m3u8;
application/dash+xml mpd;
video/mp4 mp4 m4s;
}
# Manifests: short TTL for live, moderate for VOD. Never immutable.
location ~* \.(m3u8|mpd)$ {
add_header Cache-Control "public, max-age=60, stale-while-revalidate=30";
add_header Access-Control-Allow-Origin "*";
add_header Access-Control-Expose-Headers "Content-Length,Content-Range";
gzip on; # playlists are highly compressible text
gzip_types application/vnd.apple.mpegurl application/dash+xml;
}
# Segments: content never changes under a given URL, so cache for a year.
location ~* \.(m4s|mp4)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Access-Control-Allow-Origin "*";
add_header Accept-Ranges bytes; # required for native HLS seeking
}
Warning: a wrong Content-Type on .m3u8 breaks native Safari playback with no console error — the element simply reports MEDIA_ERR_SRC_NOT_SUPPORTED. The same class of silent failure is dissected in debugging incorrect Content-Type headers for WebM videos. CORS headers are mandatory for the MSE path and irrelevant for the native path, which is why “it works in Safari but not Chrome” is almost always a CORS problem.
Step 5 — Wire the player: native first, MSE fallback
// Prefer the platform pipeline when it exists: on Safari it is hardware-backed,
// battery-efficient and keeps AirPlay + Picture-in-Picture working for free.
const HLS_MIME = 'application/vnd.apple.mpegurl';
const video = document.querySelector('#player');
const src = '/vod/abcd/master.m3u8';
async function attach() {
if (video.canPlayType(HLS_MIME)) {
video.src = src; // Safari, iOS, iPadOS, tvOS
return { path: 'native' };
}
const { default: Hls } = await import('/vendor/hls.mjs');
if (!Hls.isSupported()) {
video.src = '/vod/abcd/720p-progressive.mp4'; // last-resort single file
return { path: 'progressive' };
}
const hls = new Hls({
preferManagedMediaSource: true, // use ManagedMediaSource on Safari 17.1+
startLevel: -1, // -1 = pick from abrEwmaDefaultEstimate
abrEwmaDefaultEstimate: 1_000_000, // cold-start guess; avoids rung-0 lock-in
abrBandWidthUpFactor: 0.7, // require 30% headroom before upshifting
abrBandWidthFactor: 0.95, // downshift as soon as 95% is not met
capLevelToPlayerSize: true, // never fetch a rung wider than the element
maxBufferLength: 30, // seconds of forward buffer to target
maxMaxBufferLength: 60, // hard ceiling when bandwidth is abundant
backBufferLength: 30 // evict older media; protects mobile memory
});
hls.on(Hls.Events.ERROR, (_, data) => {
if (!data.fatal) return;
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) hls.startLoad();
else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) hls.recoverMediaError();
else hls.destroy();
});
hls.loadSource(src);
hls.attachMedia(video);
return { path: 'mse', hls };
}
Pair this with a poster image so the element has something to paint before the first segment arrives — the poster is usually the LCP candidate, and the preload strategy for it is covered in preload vs. prefetch for video and image assets. The React-side integration of this same pattern is in responsive video delivery in Next.js and React.
Parameter Reference
| Parameter | Where | Typical value | Effect |
|---|---|---|---|
-hls_time |
ffmpeg | 4 |
Target segment duration in seconds; must be a multiple of the GOP length. |
-hls_segment_type |
ffmpeg | fmp4 |
fmp4 emits CMAF; mpegts emits legacy TS that hls.js must transmux. |
keyint / min-keyint |
x264 | 60 / 60 |
Fixed closed GOP. Equal values plus scenecut=0 guarantee grid alignment. |
-maxrate / -bufsize |
ffmpeg | 1.07× / 2× avg |
Bounds the VBV so real peaks stay near the declared BANDWIDTH. |
BANDWIDTH |
HLS manifest | peak, not average | Player’s safety ceiling for rung selection. Understating causes stalls. |
EXT-X-INDEPENDENT-SEGMENTS |
HLS manifest | present | Asserts every segment starts with an IDR; enables mid-segment switching. |
@segmentAlignment |
DASH MPD | true |
DASH equivalent of the above; required for seamless Representation switch. |
@minBufferTime |
DASH MPD | PT8S (2× segment) |
Buffer the player should hold to sustain the declared @bandwidth. |
abrEwmaDefaultEstimate |
hls.js | 1_000_000 |
Cold-start bandwidth guess in bps; prevents locking onto the bottom rung. |
abrBandWidthUpFactor |
hls.js | 0.7 |
Headroom required before upshifting. Lower = more conservative. |
capLevelToPlayerSize |
hls.js | true |
Caps the ladder at the rendered element size; saves bandwidth on small embeds. |
abr.bandwidthUpgradeTarget |
shaka-player | 0.85 |
Fraction of the estimate a rung must fit under before shaka upshifts. |
streaming.abr.ABRStrategy |
dash.js | abrDynamic |
Throughput rule at startup, BOLA once the buffer is deep. |
Tradeoffs & Edge Cases
Tradeoff: more rungs means better matching but worse cache efficiency. Each additional rung splits your CDN traffic across another set of objects, lowering the hit ratio for all of them. A seven-rung ladder on a low-traffic origin can measurably increase rebuffering because more segments are served cold. Five rungs is the sweet spot below roughly 10 000 concurrent viewers; go to seven only when you have the traffic to keep them all warm.
Tradeoff: capLevelToPlayerSize fights fullscreen. Capping the ladder to the element size is correct for a 640 px embed, but when the user hits fullscreen the player must re-evaluate and upshift, which produces a visible quality jump one or two segments after the transition. Either accept the jump or disable the cap for players that are likely to go fullscreen.
Warning: audio must be aligned too, and AAC makes that hard. AAC frames are 1024 samples; at 48 kHz that is 21.33 ms, which does not divide 4 000 ms evenly. Packagers pad to the nearest frame, so audio segments drift a few milliseconds from video segments. This is harmless for playback but it means audio and video segment counts differ — do not build tooling that assumes segment N of the audio track covers the same wall-clock range as segment N of the video track.
Edge case: the bottom rung can be a trap on high-latency links. On a satellite or congested-mobile link with 600 ms RTT, a 132 KB segment spends most of its transfer time in slow start, so the measured throughput is low, so the player stays on the bottom rung, so the next segment is also small. Some players never escape. Mitigate with startLevel set to rung 1 or 2 and a realistic abrEwmaDefaultEstimate rather than starting at the floor.
Edge case: MSE buffer quota is far smaller on mobile. Desktop Chrome allows roughly 150 MB per SourceBuffer; mobile Safari’s ManagedMediaSource may evict aggressively at any time and iOS WebKit has historically enforced limits closer to 12–30 MB. A 60 s forward buffer at the 1080p rung is 36 MB of video — over the limit on some devices, producing a QuotaExceededError on append. Set backBufferLength and cap maxMaxBufferLength rather than assuming the buffer is free.
Debugging & Validation
Verify keyframe alignment across rungs
The most valuable single check. A CMAF segment is not independently probeable — you must prepend the init segment first.
# Every rung must report the same first PTS and the same IDR positions.
# Any divergence here is the root cause of "switching causes a freeze".
for r in stream_0 stream_2 stream_4; do
cat "$r/init.mp4" "$r/1.m4s" > /tmp/probe.mp4
printf '%-10s ' "$r"
ffprobe -v error -select_streams v:0 -skip_frame nokey \
-show_entries frame=pts_time -of csv=p=0 /tmp/probe.mp4 | tr '\n' ' '
echo
done
Validate the manifests
# Apple's validator checks RFC 8216 conformance, BANDWIDTH accuracy against the
# real segment sizes, and CODECS string correctness. Ships with Xcode tools.
mediastreamvalidator --hls-version 7 \
https://cdn.example.com/vod/abcd/master.m3u8 -o /tmp/validation.json
hlsreport.py /tmp/validation.json -o /tmp/report.html
# DASH: schema-validate the MPD, then confirm every declared bandwidth
# is at or above the real peak for that Representation.
xmllint --noout --schema DASH-MPD.xsd manifest.mpd
# Quick eyeball of the declared ladder.
curl -s https://cdn.example.com/vod/abcd/master.m3u8 \
| grep -E '^#EXT-X-STREAM-INF' | sed 's/,CODECS.*//'
Confirm the edge is caching segments
# A segment should return Cache-Control immutable and a cache HIT on the
# second request. A MISS on every request means the cache key includes
# something volatile - usually a signed query string or a Vary header.
curl -sI 'https://cdn.example.com/vod/abcd/stream_2/17.m4s' \
| grep -iE 'content-type|cache-control|age|x-cache|cf-cache-status'
If segments are missing at the edge, the behaviour patterns match those in debugging CloudFront cache misses for images — the diagnosis path is identical, only the object type changes.
Instrument real rung selection in the browser
// Log every segment fetch with its rung, size and effective throughput.
// Compare the throughput column against the BANDWIDTH of the rung actually
// chosen: sustained selection well below the measured rate means the
// estimator is being throttled by slow start or a stale EWMA.
hls.on(Hls.Events.FRAG_LOADED, (_, { frag }) => {
const { loading, total } = frag.stats;
const seconds = (loading.end - loading.first) / 1000;
console.log({
level: frag.level,
sn: frag.sn,
kb: Math.round(total / 1024),
mbps: +((total * 8) / seconds / 1e6).toFixed(2),
estimate: +(hls.bandwidthEstimate / 1e6).toFixed(2)
});
});
// Native-playback path: same data, from Resource Timing.
performance.getEntriesByType('resource')
.filter(e => e.name.endsWith('.m4s'))
.forEach(e => console.log(e.name.split('/').slice(-2).join('/'),
Math.round(e.transferSize / 1024) + ' KB',
+((e.transferSize * 8) / (e.duration / 1000) / 1e6).toFixed(2) + ' Mbps'));
In Chromium, chrome://media-internals gives the decoder’s own view: the selected config, every SourceBuffer append, and the exact reason for any pipeline error. It is the fastest way to distinguish “the codec string was rejected” from “the segment bytes were malformed”, two failures that surface identically as a black frame.
Related
- Building an HLS Bitrate Ladder with FFmpeg — the full encode command dissected flag by flag, with two-pass and per-title variants
- Choosing ABR Rungs for Mobile Bandwidth — rung spacing, floors and ceilings tuned to cellular throughput distributions
- DASH vs HLS for Browser Playback Support — the complete playback and DRM matrix, and how to order fallbacks
- Responsive Video Delivery in Next.js and React — mounting an ABR player inside a React tree without hydration or CLS penalties
- Implementing Responsive Video with Video.js — using the VHS engine to drive the same CMAF segment set
- Understanding Video Codecs: VP9 vs H.265 vs AV1 — the codec economics behind adding a second ladder above the H.264 baseline
- Responsive Image & Video Delivery — parent reference covering the full responsive media negotiation model