DASH vs HLS for Browser Playback Support
You have one CMAF segment set and two index files that describe it — master.m3u8 and manifest.mpd — and now you have to decide, per visitor, which one to hand the <video> element and whether to load a JavaScript player at all. The constraint is narrow and non-negotiable: no browser plays both formats natively, and one very large browser plays neither. WebKit parses HLS in the platform media stack and has never shipped a DASH parser; Chromium and Gecko parse neither and require Media Source Extensions for both; and iPhone Safari had no MediaSource object at all until ManagedMediaSource arrived in 17.1. This page is the runtime selection logic that resolves that matrix without user-agent sniffing, sitting under Adaptive Bitrate Streaming with HLS and DASH in the wider Responsive Image & Video Delivery section.
The goal is not “support both protocols everywhere”. It is to ship the smallest amount of JavaScript that produces a playing video on every engine, and to fail into a working path rather than a black rectangle when a probe is wrong.
Prerequisite checklist
Probe the engine, never the user agent
Five expressions tell you everything you need. Evaluate them in this order; the first three are synchronous and cost microseconds, the fourth is synchronous but string-sensitive, and the fifth is a promise you only need when DRM is in play.
video.canPlayType('application/vnd.apple.mpegurl') returns "probably", "maybe" or "". On WebKit it is "probably" when the platform can hand the playlist straight to the media stack. window.MediaSource tells you whether the classic MSE path exists. window.ManagedMediaSource tells you whether the UA-managed variant exists — it is the only MSE surface on iPhone. MediaSource.isTypeSupported(mime) (or ManagedMediaSource.isTypeSupported) validates your specific codec string. And navigator.requestMediaKeySystemAccess() decides your DRM branch: FairPlay on WebKit, Widevine on Chromium and Gecko, PlayReady on Edge and Windows Chromium.
The grid below is what those five probes actually return on the four engines that matter. Note row two: window.MediaSource is genuinely undefined on iPhone, which is why every “just use dash.js everywhere” plan dies there.
Two engines with identical probe results get identical treatment, which is the entire point: Chrome on iOS, Edge on iOS and Firefox on iOS all report "probably" for the HLS MIME type and undefined for MediaSource, because they are all WebKit under a different shell. A UA regex would have routed them to dash.js and produced a blank player.
The complete solution: a probe-first source attacher
One function, three outcomes, one dynamic import at most. Every option is annotated.
// adaptive-source.js — attach the cheapest working playback path for this engine.
//
// Sources object shape:
// { hls: '/vod/abc/master.m3u8', dash: '/vod/abc/manifest.mpd' }
// Both index the SAME CMAF segments, so the CDN cache is unaffected by the choice.
const HLS_MIME = 'application/vnd.apple.mpegurl';
// The codec string must match your ladder's TOP rung. Probing the bottom rung
// passes on hardware that cannot decode the rung the ABR loop will climb to.
const PROBE_MIME = 'video/mp4; codecs="avc1.640028,mp4a.40.2"';
export async function attachAdaptiveSource(video, sources, opts = {}) {
const {
preferNative = true, // native first: hardware decode path, AirPlay, PiP, lowest power draw
startLevel = -1, // -1 lets the ABR estimator pick rung 0..n; set 1 to skip the 234p floor
capToSize = true, // never fetch a rung larger than the element's CSS pixel box
} = opts;
// --- Path 1: native HLS. Zero JavaScript, zero transmux, full platform pipeline. ---
// canPlayType returns "probably" | "maybe" | "". Treat "maybe" as usable:
// WebKit reports "maybe" for the legacy x-mpegURL spelling on some builds.
if (preferNative && video.canPlayType(HLS_MIME) !== '') {
video.src = sources.hls;
// Wait for the media stack to accept it. If it rejects the playlist the
// element fires `error` with MEDIA_ERR_SRC_NOT_SUPPORTED and we fall through.
const ok = await onceEither(video, 'loadedmetadata', 'error', 8000);
if (ok === 'loadedmetadata') return { engine: 'native-hls', bytesOfJs: 0 };
video.removeAttribute('src');
video.load(); // MUST call load() after clearing src, or the element keeps the failed resource
}
// --- Path 2: MSE. ManagedMediaSource is the ONLY MSE surface on iPhone. ---
const MSE = window.ManagedMediaSource ?? window.MediaSource;
if (!MSE || !MSE.isTypeSupported(PROBE_MIME)) {
throw new Error('no playable path: neither native HLS nor a usable MediaSource');
}
// Serve HLS to WebKit-flavoured MSE and DASH to everything else ONLY if you
// have a reason to; both libraries handle both. The real driver is DRM:
// FairPlay ships HLS + cbcs, Widevine/PlayReady ship DASH + cenc or cbcs.
const wantsDash = !window.ManagedMediaSource && Boolean(sources.dash) && opts.drm === 'widevine';
if (wantsDash) {
// Dynamic import: this chunk never reaches a WebKit visitor.
const { default: shaka } = await import('shaka-player/dist/shaka-player.compiled.js');
shaka.polyfill.installAll();
const player = new shaka.Player();
await player.attach(video);
player.configure({
abr: {
enabled: true,
restrictions: capToSize ? { maxHeight: video.clientHeight * devicePixelRatio } : {},
defaultBandwidthEstimate: 1_200_000, // seed in bit/s; without it rung 0 is chosen for ~2 segments
},
streaming: {
bufferingGoal: 30, // seconds ahead to keep buffered
rebufferingGoal: 4, // seconds required before playback resumes after a stall
retryParameters: { maxAttempts: 4, baseDelay: 500, backoffFactor: 2 },
},
});
await player.load(sources.dash);
return { engine: 'shaka-dash', bytesOfJs: 121_000 };
}
// --- Path 3: hls.js over MSE or ManagedMediaSource (Chromium, Gecko, iPad, iPhone fallback). ---
const { default: Hls } = await import('hls.js/dist/hls.light.mjs');
if (!Hls.isSupported()) throw new Error('hls.js reports no MSE support after probe passed');
const hls = new Hls({
preferManagedMediaSource: true, // default since 1.5; forces the iPhone-compatible surface
startLevel, // -1 = let ABR choose; a fixed index pins the first segment
capLevelToPlayerSize: capToSize, // clamps the ladder to the element box — see the ABR guide
maxBufferLength: 30, // seconds of forward buffer; ManagedMediaSource may evict below this
backBufferLength: 30, // trim behind the playhead so mobile buffer quota is not exhausted
lowLatencyMode: false, // VOD: disable LL-HLS part fetching and its extra request volume
xhrSetup: (xhr) => { xhr.withCredentials = Boolean(opts.withCredentials); },
});
hls.on(Hls.Events.ERROR, (_evt, data) => {
if (!data.fatal) return; // non-fatal errors are retried internally
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) hls.startLoad(); // re-request the failed segment
else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) hls.recoverMediaError(); // flush + re-append buffers
else { hls.destroy(); video.poster = opts.fallbackPoster ?? ''; } // give up cleanly, never silently
});
hls.loadSource(sources.hls);
hls.attachMedia(video); // attach AFTER loadSource: hls.js queues the manifest either way
return { engine: 'hls.js', bytesOfJs: 92_000 };
}
// Resolve with whichever event fires first, or 'timeout'. Prevents a hung probe
// from leaving the element in a permanently empty state on flaky networks.
function onceEither(target, a, b, ms) {
return new Promise((resolve) => {
const done = (name) => { target.removeEventListener(a, onA); target.removeEventListener(b, onB); clearTimeout(t); resolve(name); };
const onA = () => done(a), onB = () => done(b);
const t = setTimeout(() => done('timeout'), ms);
target.addEventListener(a, onA, { once: true });
target.addEventListener(b, onB, { once: true });
});
}
The payoff is measurable in transferred bytes, not just tidiness. Eagerly importing both libraries so “it works everywhere” costs every visitor the union of both bundles — including the WebKit visitors who need neither.
Tradeoff: the native path gives up the ABR controls the parent guide describes — you cannot set a start level, cap the ladder to the element size, or attach per-segment auth headers. If your product needs any of those on WebKit too, force path 2 by passing preferNative: false and accept the 92 KB plus the loss of AirPlay unless you also keep a <source> element around for the remote-playback picker.
The attach sequence is a small state machine, and drawing it out is the difference between “it fell back” and “it fell back twice and left the element holding a dead resource”.
Verification
1. Confirm the branch in the console
Paste this into each engine’s console on a page that has already attached a source. It prints the observed decision rather than the intended one.
const v = document.querySelector('video');
console.table([{
canPlayHls: v.canPlayType('application/vnd.apple.mpegurl') || '(empty)',
mse: typeof window.MediaSource,
managedMse: typeof window.ManagedMediaSource,
// A native attach leaves a real URL in .src; an MSE attach leaves a blob: URL.
srcKind: v.currentSrc.startsWith('blob:') ? 'MSE' : 'native',
readyState: v.readyState, // 4 = HAVE_ENOUGH_DATA
buffered: v.buffered.length ? v.buffered.end(0).toFixed(1) + ' s' : 'none',
}]);
Expected: Safari reports native with a .m3u8 in currentSrc; Chrome and Firefox report MSE with a blob: URL. Anything else means the probe fell through in a way you did not intend.
2. Check the network cost in DevTools
Filter the Network panel by JS. On WebKit you should see zero player chunks and a request sequence of master.m3u8 → index.m3u8 → init.mp4 → 1.m4s. On Chromium you should see exactly one chunk (hls.light-*.mjs or shaka-*.js) — two chunks means both libraries got bundled into the entry graph and your dynamic import was hoisted by the bundler.
3. Validate MIME types and CORS over the wire
# WebKit refuses a playlist that is not one of the two registered HLS types.
curl -sI https://media.example.com/vod/abc/master.m3u8 \
| grep -iE 'content-type|access-control-allow-origin|cache-control'
# content-type: application/vnd.apple.mpegurl
# access-control-allow-origin: https://www.example.com
curl -sI https://media.example.com/vod/abc/manifest.mpd | grep -i content-type
# content-type: application/dash+xml
# MSE issues ranged segment reads; the browser must be allowed to read the range headers.
curl -sI -H 'Origin: https://www.example.com' -H 'Range: bytes=0-1023' \
https://media.example.com/vod/abc/stream_2/1.m4s \
| grep -iE 'HTTP/|content-range|access-control-expose-headers'
# HTTP/2 206
# content-range: bytes 0-1023/648221
# access-control-expose-headers: Content-Length, Content-Range
A Content-Type of application/octet-stream on the playlist is the single most common reason native HLS “just doesn’t work” on iPhone while hls.js is fine on the desktop — the JS player never consults the header.
4. Assert progress in all three engines
// playback.spec.js — run with: npx playwright test --project=webkit --project=chromium --project=firefox
import { test, expect } from '@playwright/test';
test('video reaches 2 s of playback', async ({ page, browserName }) => {
await page.goto('https://www.example.com/watch/abc');
const advanced = await page.evaluate(async () => {
const v = document.querySelector('video');
v.muted = true; // required: autoplay with sound is blocked without a gesture
await v.play();
await new Promise(r => setTimeout(r, 6000));
return { t: v.currentTime, src: v.currentSrc.slice(0, 5), err: v.error?.code ?? null };
});
expect(advanced.err).toBeNull();
expect(advanced.t).toBeGreaterThan(2);
// WebKit must be on the native path; the others must be on a blob: MediaSource URL.
expect(advanced.src).toBe(browserName === 'webkit' ? 'https' : 'blob:');
});
Common mistakes
1. Branching on the user-agent string
Every iOS browser is WebKit, and iPadOS Safari reports a macOS user agent by default. A regex for iPhone|iPad misses Chrome for iOS, and a regex for Safari matches Chrome on desktop (whose UA still contains the token). The probe is one synchronous call and is always correct; the regex is never fully correct.
// WRONG — routes Chrome for iOS (WebKit, no MediaSource) into the dash.js path.
if (/iPhone|iPad|Safari/.test(navigator.userAgent)) { useNativeHls(); } else { useDash(); }
// CORRECT — ask the engine what it can do.
if (video.canPlayType('application/vnd.apple.mpegurl') !== '') { useNativeHls(); }
else if (window.ManagedMediaSource ?? window.MediaSource) { useMsePlayer(); }
2. Testing window.MediaSource only, and shipping nothing to iPhone
window.MediaSource is undefined on iPhone Safari at every version; ManagedMediaSource is the surface Apple shipped in 17.1. Code that guards on the former alone hands iPhone users a permanently empty element the moment you force a non-native path.
// WRONG — iPhone falls off the end of this branch with no player at all.
if (window.MediaSource) { startHlsJs(); }
// CORRECT — coalesce, and tell hls.js which surface to use.
const MSE = window.ManagedMediaSource ?? window.MediaSource;
if (MSE) new Hls({ preferManagedMediaSource: true }).attachMedia(video);
3. Setting video.src and attaching an MSE player to the same element
Two attach paths on one element race: the native load algorithm aborts the MediaSource object URL, or vice versa, and which one wins depends on task ordering. The symptom is intermittent — it works locally and fails on a slow connection.
// WRONG — the element now has two competing resource selections.
video.src = '/vod/abc/master.m3u8';
hls.loadSource('/vod/abc/master.m3u8');
hls.attachMedia(video);
// CORRECT — clear the native resource and reset the element before attaching MSE.
video.removeAttribute('src');
video.load(); // aborts the pending native resource selection
hls.loadSource('/vod/abc/master.m3u8');
hls.attachMedia(video);
4. Probing with a codec string that is not the one you ship
isTypeSupported() compares strings, not capabilities. 'video/mp4' alone returns true almost everywhere and tells you nothing, while a full string for a rung you do not encode sends users down a path that fails at the first appendBuffer. Probe the top rung’s exact CODECS value — the codec economics behind which rungs to encode are covered in understanding video codecs: VP9 vs H.265 vs AV1 and the device split in hardware AV1 decode support by device class.
// WRONG — passes everywhere, predicts nothing.
MediaSource.isTypeSupported('video/mp4');
// WRONG — a decoder-level string with no profile/level suffix.
MediaSource.isTypeSupported('video/mp4; codecs="avc1"'); // false in Chromium
// CORRECT — the literal string from your top rung's manifest entry.
MediaSource.isTypeSupported('video/mp4; codecs="avc1.640028,mp4a.40.2"');
5. Serving DASH to Safari because “shaka-player supports Safari”
It does — over MSE, which means you have discarded the hardware pipeline, AirPlay, Picture-in-Picture and the FairPlay path, and on iPhone you are relying on ManagedMediaSource where the UA may evict your buffer at any time. Ship HLS to WebKit and reserve DASH for the engines that need Widevine or PlayReady. If your player wrapper obscures this decision, the source-order pattern in implementing responsive video with Video.js shows how to keep a native <source> element alive alongside a JS engine.
Warning: the same rule applies to preload hints. A <link rel="preload" as="fetch"> for a manifest you may never load wastes the connection on WebKit and duplicates the player’s own request on Chromium — see preload vs prefetch for video and image assets before adding one.
Related
- Adaptive Bitrate Streaming with HLS and DASH — the ladder, CMAF packaging and manifest structure this page assumes you already have
- Implementing Responsive Video with Video.js — wiring the same engine choice through a player framework
- Understanding Video Codecs: VP9 vs H.265 vs AV1 — which codec strings are safe to put in a manifest for each engine
- Debugging Incorrect Content-Type Headers for WebM Videos — the header-level debugging that manifest MIME problems share