Playing HLS in the Browser with hls.js
Feature-detect: when Hls.isSupported() is true, attach an Hls instance, load the source, and handle Hls.Events.ERROR by calling startLoad() on network errors and recoverMediaError() on media errors before giving up; only when it is false and video.canPlayType("application/vnd.apple.mpegurl") is truthy, fall back to setting video.src to the playlist.
The player is the last link in the chain that starts with an upload. Everything upstream — the encoding ladder, the aligned segments, the CDN headers — only shows its value if the player picks sensible rungs and survives the network. This page sits under adaptive bitrate video streaming in media processing and delivery pipelines, and it assumes a package like the one produced by packaging HLS with FFmpeg and fMP4 segments.
When to use this approach
- You serve HLS and need it to play in Chrome, Firefox and Edge on desktop, where there is no native HLS support, as well as in Safari.
- You want control over rung selection — start level, caps tied to the player’s rendered size, bandwidth estimation — rather than whatever the browser decides.
- You need to surface useful errors: an expired signed URL, a missing segment, an unsupported codec. A bare
<video src>gives youMEDIA_ERR_SRC_NOT_SUPPORTEDfor all three.
Prerequisites
hls.js1.5 or newer (npm i hls.js), imported as an ES module.- An HLS package served over HTTPS with CORS allowing your page origin — see the gotchas below.
- A bundler or native ES modules; the code below is plain TypeScript with DOM types.
- For iOS Safari before 17.1, no Media Source Extensions in the page: native playback is the only path, which the detection below handles.
How the player chooses a rung
hls.js loads the master playlist, picks a start level, fetches that rung’s playlist and init segment, then measures how long each segment download takes. From those samples it keeps two exponentially weighted moving averages of throughput — a fast one and a slow one — and uses the lower of the two, multiplied by a safety factor, to choose the next segment’s rung. It also caps the rung at the video element’s rendered size if you ask it to.
This is why the declared BANDWIDTH in your master playlist matters: it is compared directly against the estimate. Understate it and the player picks a rung it cannot sustain; overstate it and viewers on good connections sit one rung lower than they need to.
Implementation
import Hls, { type ErrorData, type HlsConfig } from "hls.js";
export interface PlayerHandle {
destroy(): void;
levels(): { height: number; bitrate: number }[];
setMaxHeight(px: number | null): void;
}
const CONFIG: Partial<HlsConfig> = {
capLevelToPlayerSize: true, // never fetch 1080p into a 360-pixel-tall player
startLevel: -1, // auto: start from the bandwidth estimate below
abrEwmaDefaultEstimate: 1_500_000, // first-segment guess (bps) before any samples exist
maxBufferLength: 30, // seconds of forward buffer to aim for
backBufferLength: 30, // free decoded data behind the playhead (memory on mobile)
fragLoadPolicy: {
default: {
maxTimeToFirstByteMs: 8_000,
maxLoadTimeMs: 20_000,
timeoutRetry: { maxNumRetry: 2, retryDelayMs: 0, maxRetryDelayMs: 0 },
errorRetry: { maxNumRetry: 4, retryDelayMs: 1_000, maxRetryDelayMs: 8_000 },
},
},
};
export function attachPlayer(
video: HTMLVideoElement,
src: string,
onFatal: (message: string) => void,
): PlayerHandle {
// Prefer MSE via hls.js where available — it gives us ABR control and real error events.
if (Hls.isSupported()) {
const hls = new Hls(CONFIG);
let mediaRecoveries = 0;
hls.on(Hls.Events.ERROR, (_evt, data: ErrorData) => {
if (!data.fatal) return; // non-fatal: hls.js already retried
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR: {
const status = data.response?.code;
if (status === 403 || status === 401) {
onFatal("This video link has expired. Reload the page to get a fresh one.");
hls.destroy();
return;
}
hls.startLoad(); // resume from the current position
return;
}
case Hls.ErrorTypes.MEDIA_ERROR:
if (mediaRecoveries++ < 2) {
hls.recoverMediaError(); // reset the SourceBuffers, keep position
return;
}
onFatal("This video could not be decoded on this device.");
hls.destroy();
return;
default:
onFatal(`Playback failed: ${data.details}`);
hls.destroy();
}
});
hls.on(Hls.Events.MANIFEST_PARSED, (_evt, data) => {
// Start on the 720p-class rung if it exists; the estimate takes over after segment one.
const idx = data.levels.findIndex((l) => l.height >= 700 && l.height <= 760);
if (idx >= 0) hls.startLevel = idx;
});
hls.loadSource(src);
hls.attachMedia(video);
return {
destroy: () => hls.destroy(),
levels: () => hls.levels.map((l) => ({ height: l.height, bitrate: l.bitrate })),
setMaxHeight: (px) => {
const allowed = px === null ? hls.levels.length - 1
: hls.levels.reduce((best, l, i) => (l.height <= px ? i : best), 0);
hls.autoLevelCapping = px === null ? -1 : allowed;
},
};
}
// Safari without MSE (older iOS): native HLS. Less control, but it plays.
if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = src;
video.addEventListener("error", () => {
const code = video.error?.code;
onFatal(code === MediaError.MEDIA_ERR_NETWORK
? "Network error while loading the video."
: "This video could not be played.");
}, { once: true });
return {
destroy: () => { video.removeAttribute("src"); video.load(); },
levels: () => [],
setMaxHeight: () => { /* native HLS exposes no rung control */ },
};
}
onFatal("This browser cannot play streaming video.");
return { destroy: () => {}, levels: () => [], setMaxHeight: () => {} };
}
// Usage
const video = document.querySelector<HTMLVideoElement>("#player");
if (video) {
const player = attachPlayer(video, "https://media.example.com/v/8f3a/master.m3u8", (msg) => {
const box = document.querySelector("#player-error");
if (box) box.textContent = msg;
});
window.addEventListener("pagehide", () => player.destroy(), { once: true });
}
Line-by-line on the settings that matter
Hls.isSupported()first, native second. Safari on macOS supports both MSE and native HLS. hls.js on MSE gives you error details, rung control and consistent behaviour across browsers; native HLS in Safari is fine, but it reports every failure as a generic media error. Recent iOS versions exposeManagedMediaSource, which hls.js 1.5 uses automatically.capLevelToPlayerSize: trueis the single most valuable setting for mobile data. A 1080p rung in a 360-pixel-tall embed downloads three times the bytes for no visible gain.abrEwmaDefaultEstimateis the guess used before any segment has been measured. The default of 500 kbps starts most viewers on the lowest rung, which looks bad for the first few seconds. Seeding 1.5 Mbps and forcing a 720p-class start is a better first impression on typical connections.backBufferLength: 30frees decoded media behind the playhead. Without it, a long video on a phone accumulates buffered data until the browser evicts it under memory pressure, sometimes by crashing the tab.fragLoadPolicyseparates timeouts from errors. A segment that takes 20 seconds is retried twice immediately (the next attempt may hit a warmer edge); a 5xx is retried four times with backoff up to 8 s.- 403/401 is terminal. When segments sit behind signed URLs or signed cookies, a 403 halfway through a video means the signature expired. Retrying the same URL cannot work; the page needs a fresh signature.
recoverMediaError()at most twice. It tears down and recreates the SourceBuffers, which fixes transient decoder hiccups. If it keeps failing, the codec genuinely is not supported, and a loop would just stall the player.
Error handling as a decision tree
Configuration gotchas
Access to XMLHttpRequest at '…/master.m3u8' from origin '…' has been blocked by CORS policy. hls.js fetches playlists and segments with XHR/fetch, so unlike a plain <video src> they are subject to CORS. The bucket or CDN must return Access-Control-Allow-Origin for your page origin on the playlist, the init segment and every media segment. Native Safari playback does not need CORS, which is why “it works on my iPhone” is a common false signal. The CORS rules themselves are covered in configuring CORS for GCS and Azure Blob uploads and apply to reads as much as uploads.
manifestIncompatibleCodecsError. Every variant’s CODECS string names something MediaSource.isTypeSupported() rejects — typically HEVC (hvc1) on Firefox or Chrome without hardware support. Keep an H.264 ladder alongside any HEVC or AV1 one and let the player filter.
bufferAppendError on the first segment. The init segment and the media segments disagree — usually because a re-package overwrote segments but the CDN still serves the old init.mp4. Version the package path (/v/<hash>/…) rather than overwriting in place.
Autoplay fails silently. Browsers block autoplay with sound. Set video.muted = true and playsInline before calling play(), and catch the rejected promise to show a play button instead.
What the viewer experiences
The throughput trace below is a real mobile session replayed against the ladder from this section: the player starts on 720p, steps down twice as the connection degrades on a train, and recovers within two segments once it improves.
Verification
In the browser console on a page using the handle above:
// 1. The ladder the player sees matches the master playlist.
console.table(player.levels());
// 2. Rung choice follows the element size: shrink the player and watch the cap drop.
video.style.height = "240px";
video.dispatchEvent(new Event("resize"));
// 3. Force a network failure and confirm recovery rather than a stall:
// DevTools → Network → block request URL pattern "*.m4s", wait 5 s, then unblock.
// Expect: ERROR events with fatal=false, then playback resumes without a reload.
And from a terminal, confirm CORS on every object type the player touches:
for p in master.m3u8 720p/index.m3u8 720p/init.mp4 720p/seg_000.m4s; do
curl -s -o /dev/null -D - -H "Origin: https://app.example.com" \
"https://media.example.com/v/8f3a/$p" | grep -i '^access-control-allow-origin' \
|| echo "MISSING CORS on $p"
done
Frequently Asked Questions
Should I use the native player in Safari instead of hls.js?
Either works. Native HLS in Safari has excellent power efficiency and handles AirPlay and Picture-in-Picture without extra code, but you lose detailed error events and rung control. Many teams use hls.js wherever Hls.isSupported() is true and fall back to native, as above, which keeps behaviour and analytics consistent.
Why does quality start low for the first few seconds?
The player has no bandwidth samples yet, so it uses abrEwmaDefaultEstimate. Raising it, or forcing a mid-ladder startLevel, trades a small risk of an initial rebuffer on slow links for a much better first impression on normal ones. Short segments also help, because the first real measurement arrives sooner.
Can hls.js play DASH?
No. Use dash.js or Shaka Player for DASH manifests. Shaka Player plays both HLS and DASH, which makes it attractive if you publish both from Shaka Packager.