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.

Capability probe results by browser engine A five row grid. canPlayType for the Apple mpegurl MIME type returns probably on both Safari builds and an empty string on Chrome and Firefox. window.MediaSource is a function everywhere except iPhone Safari where it is undefined. window.ManagedMediaSource is a function only on Safari 17.1 and newer. isTypeSupported for avc1.640028 returns true on all four. The available key system is FairPlay on Safari and Widevine on Chrome and Firefox. Probe expression Safari 17 macOS Safari 17.1 iPhone Chrome 120 Chromium Firefox 128 Gecko video.canPlayType( 'application/vnd.apple.mpegurl') "probably" "probably" "" "" window.MediaSource function undefined function function window.ManagedMediaSource function function undefined undefined MediaSource.isTypeSupported( 'video/mp4; codecs="avc1.640028"') exact RFC 6381 string required true true true true requestMediaKeySystemAccess(…) first key system that resolves FairPlay FairPlay Widevine Widevine Every value here is observable in one synchronous tick — no network request and no user-agent string involved.

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.

Player JavaScript transferred before the first frame Five horizontal bars showing gzipped player JavaScript per session. Eagerly importing hls.js and shaka together costs 213 kilobytes on every browser. Eager hls.js alone costs 118 kilobytes. A probe that lands on native HLS costs zero. A probe that dynamically imports the hls.js light build costs 92 kilobytes. A probe that dynamically imports shaka for Widevine DASH costs 121 kilobytes. Player JS transferred before first frame (gzip, per session) Eager: hls.js + shaka 213 Eager: hls.js full build 118 Probe → native HLS (WebKit) 0 — platform media stack, no bundle at all Probe → hls.light (Chromium) 92 Probe → shaka (Widevine DASH) 121 0 50 100 150 200 kilobytes transferred (gzip) Roughly 55% of a consumer video audience is WebKit on mobile — for them the correct bundle size is zero.

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”.

Source attachment state machine with ordered fallback From idle the function moves to probing and builds an ordered candidate list. Each candidate — native HLS, ManagedMediaSource and full MSE — is attempted in turn. A candidate that reaches loadedmetadata transitions to playing. A candidate that errors returns to a pop-next-candidate state which retries the following candidate, and when the list is empty the machine enters a terminal error UI state. idle probing ordered candidate list native <video src> master.m3u8 ManagedMediaSource hls.js, iPhone 17.1+ MediaSource hls.js or shaka + DASH loadedmetadata within 8 s playing currentTime advances attach failed SRC_NOT_SUPPORTED / timeout removeAttribute('src') + load() pop next candidate terminal: poster + download link list empty Clearing src without calling load() is the classic bug: the element keeps the failed resource and the next candidate never loads.

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.m3u8index.m3u8init.mp41.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.