ABR Streaming
Back to blog
Playback & QoE

Video Players Explained: MSE, EME, hls.js, dash.js, and Shaka

How browser video players work: MSE and EME, the buffering model, ABR hooks, how hls.js/dash.js/Shaka compare, native vs MSE on Safari, and common pitfalls.

ABR Streaming Team5 min read
PlayersMSEEMEhls.jsShaka

The player is where every other part of the streaming stack — your ladder, your ABR design, your DRM, your CDN — gets cashed out into an actual viewing experience. Modern web players are surprisingly deep pieces of software built on two browser APIs: MSE for feeding media and EME for decrypting it. This post explains how players work under the hood, how the major JavaScript players differ, and the pitfalls that bite teams in production. Inspect manifests these players consume with the manifest tool.

MSE: feeding the decoder yourself

A plain <video src="movie.mp4"> lets the browser handle everything. Adaptive streaming can't work that way — the player needs to choose renditions and splice segments dynamically. MSE (Media Source Extensions) is the API that makes this possible: instead of a URL, you attach a MediaSource and push media bytes into SourceBuffers yourself.

The model:

  1. Create a MediaSource, attach it via video.src = URL.createObjectURL(ms).
  2. On sourceopen, add a SourceBuffer with a codec string (addSourceBuffer('video/mp4; codecs="avc1.640028"')).
  3. Fetch each media segment and sourceBuffer.appendBuffer(bytes).
  4. The browser demuxes, decodes, and renders from the buffer.

Everything else — manifest parsing, segment scheduling, ABR, error recovery — is your JavaScript. That's exactly what hls.js, dash.js, and Shaka implement on top of MSE.

The buffering model

MSE exposes a buffered TimeRanges object: the spans of media currently held, in presentation time. The player's job is to keep the buffer ahead of the playhead:

  • Forward buffer (buffer length / bl) — how many seconds are buffered ahead of currentTime. When this hits zero, playback stalls (a rebuffer event).
  • Buffer targets — players aim for a target (e.g. 30s for VOD), fetching the next segment whenever the buffer drops below it; low-latency live runs a much thinner buffer.
  • EvictionSourceBuffers have memory limits; players evict already-played ranges to stay under quota (a QuotaExceededError on append means trim first).
  • Gaps — discontinuities or missing frames create gaps the player must detect and "jump" over, or playback wedges.

The player fires waiting/stalled when the buffer starves and playing when it recovers — these are exactly the events a QoE beacon SDK hooks (see QoE metrics that matter).

ABR hooks

The ABR controller decides which rendition to fetch next. It typically blends:

  • Throughput estimation — bandwidth measured from recent segment downloads.
  • Buffer occupancy — how full the buffer is; buffer-based algorithms (e.g. BOLA in dash.js) pick quality from buffer level rather than raw bandwidth.
  • Constraints — screen size, max bitrate caps, and player state.

Good players expose hooks so you can override the choice — cap quality on metered networks, force a starting rendition for fast startup, or feed your own estimate. Players also emit CMCD on each request so the CDN can see buffer and bitrate state; see the CMCD tool.

The major JavaScript players

hls.js

  • Scope: HLS only, on MSE. The de facto way to play HLS in Chrome/Firefox/ Edge, which lack native HLS.
  • Strengths: lightweight, fast startup, huge adoption, great for HLS-first catalogs. Handles LL-HLS parts.
  • Notes: doesn't play DASH; DRM via EME is supported but less central than in Shaka.

dash.js

  • Scope: the DASH reference player (DASH-IF), MSE-based.
  • Strengths: the most complete DASH implementation, advanced ABR (BOLA, dynamic), strong low-latency DASH support, the place new DASH features land first.
  • Notes: DASH only.

Shaka Player

  • Scope: Google's player; plays both DASH and HLS on MSE, with offline storage and strong multi-DRM.
  • Strengths: unified API across formats, robust EME handling for Widevine/ PlayReady/FairPlay, good for services spanning both protocols.
  • Notes: larger and more configuration surface than hls.js.

A practical rule: HLS-only catalog → hls.js; DASH-centric → dash.js; both formats and serious DRM → Shaka. Commercial players (Video.js with plugins, THEOplayer, Bitmovin) wrap these or ship their own engines with unified UIs and support.

Native vs MSE playback — the Safari problem

Safari (and iOS) is the big asymmetry:

  • Safari/iOS play HLS natively through the <video> element — no MSE needed. iOS historically restricted or lacked full MSE on the phone, so on iPhone you often must use native HLS playback.
  • That means on Safari your JS player frequently hands the URL to the native player rather than driving MSE itself, so you lose fine-grained control over buffering and ABR and inherit Safari's behavior.
  • DASH does not play natively anywhere; it always needs MSE. So a DASH-only service needs an HLS fallback (or CMAF served as HLS) for iPhone.

This is why CMAF matters: one set of CMAF segments can be addressed by an HLS manifest (for native Safari/iOS) and a DASH manifest (for MSE elsewhere) without re-encoding. See HLS vs MPEG-DASH.

Common player pitfalls

  • Codec string mismatches. addSourceBuffer with the wrong codecs profile (e.g. claiming avc1.640028 but shipping a different level) silently fails to decode on some devices.
  • Discontinuity handling. Ad splices and timeline gaps need correct EXT-X-DISCONTINUITY / period boundaries, or the player wedges at the seam.
  • Audio/video desync. Independent SourceBuffers drifting, or mismatched segment durations across tracks.
  • Buffer eviction bugs. Not trimming leads to QuotaExceededError; trimming too aggressively causes re-downloads.
  • Startup rendition too high. A high first rendition spikes startup time and exits-before-start; too low wastes the connection. Tune the initial choice.
  • EME/DRM edge cases. Wrong key system order, missing PSSH, or cbcs content on a system expecting cenc — playback just fails (see DRM and content protection).
  • Safari assumptions. Code that assumes MSE everywhere breaks on iPhone; always test the native HLS path.

Takeaways

Web players are MSE-driven byte pumps with an ABR brain and an EME path for DRM; hls.js, dash.js, and Shaka each occupy a clear niche. The recurring traps are codec strings, discontinuities, buffer management, and the Safari native-HLS split — all of which CMAF and careful manifest authoring mitigate. Validate the manifests your player ingests in the manifest tool.

Try it yourself

Reading about streaming is one thing — pull apart a real manifest, play a stream, and measure its QoE with the toolkit.

Browse all tools