hls.js Guide: Playing HLS Streams in the Browser

hls.js plays HTTP Live Streaming (HLS) in browsers that lack native HLS support. It fetches playlists and segments over HTTP, transmuxes MPEG-TS into fragmented MP4 when needed, and feeds the result to Media Source Extensions (MSE). A working hls player with error recovery, tuned adaptive bitrate (ABR) and correct CDN headers takes about 45 minutes to build, and most of that time goes into headers and failure handling.

The hls.js 1.x defaults, as documented in 2026, buffer 30 seconds of media ahead of the playhead and keep the entire back buffer. They also start ABR from a 500 kbps bandwidth estimate, so the first segments usually load at a low rendition. Production players should change two of those three defaults.

How hls.js plays HLS when the browser has no native support

hls.js is a JavaScript library that downloads the HLS multivariant playlist, picks a rendition, and pulls media segments with XHR or fetch. It then remuxes them in a Web Worker and appends them to a SourceBuffer. The browser decodes, and hls.js does everything else: playlist refresh, ABR, retries and buffer management.

Safari has played HLS natively for years. On iPhone, MSE support arrived as ManagedMediaSource in iOS 17.1, and hls.js uses it from version 1.5 onward. For any target, check MSE support first and fall back to native HLS only where MSE is missing. That gives you a single code path for ABR logic and analytics on almost every device.

Prerequisites

  • A packaged HLS stream: a multivariant playlist, at least two renditions, and segments in MPEG-TS or fMP4 (CMAF).
  • A CDN or web server where you control response headers: CORS, Cache-Control and Content-Type.
  • A frontend build with npm, plus a page containing a video element with the id "player".
  • Browser DevTools, with the Network panel open and caching disabled during testing.

Play HLS in browser tabs: the step-by-step setup

  1. Install the library. Run npm install hls.js, then import the default export Hls from the hls.js package. Why: the npm build ships TypeScript types and the worker, so you do not have to host files yourself.
  2. Feature-detect before creating anything. Call Hls.isSupported(). If it returns true, use hls.js. If it returns false and video.canPlayType("application/vnd.apple.mpegurl") returns a non-empty string, set video.src to the playlist URL and let the browser play natively. Why: this order keeps MSE-capable Safari on hls.js, so ABR behavior and telemetry stay consistent across browsers.
  3. Create the instance with explicit config. Pass these keys to new Hls: enableWorker set to true, startLevel set to -1, capLevelToPlayerSize set to true, maxBufferLength set to 30, backBufferLength set to 90, and abrEwmaDefaultEstimate set to a realistic value for your audience (for example 3000000, meaning 3 Mbps). Why: the 500 kbps default estimate starts most viewers on the lowest rendition, and an unbounded back buffer leaks memory on long sessions.
  4. Attach media, then load the source. Call hls.attachMedia(video), then hls.loadSource(PLAYLIST_URL), where PLAYLIST_URL is the absolute HTTPS URL of your multivariant playlist. Start playback after the Hls.Events.MANIFEST_PARSED event fires. Why: hls.js parses the playlist before it chooses a level, and calling play() earlier triggers autoplay rejections.
  5. Register an error handler before the first request. Listen to Hls.Events.ERROR and branch on data.fatal and data.type. Step 5 of the error section below covers the recovery logic. Why: without a handler, a single fatal network error leaves a frozen frame and no log line.
  6. Destroy on teardown. Call hls.destroy() when the component unmounts or the source changes. Why: an undestroyed instance keeps its worker, its timers and its SourceBuffers alive.

How to handle hls.js errors without reloading the page

hls.js reports every error through one event. Only errors with data.fatal set to true stop playback. Non-fatal errors, such as bufferStalledError during a bandwidth dip, are normal and get retried internally. Your handler only needs to act on fatal errors, and the right action depends on the error type.

  • NETWORK_ERROR, fatal: internal retries are exhausted. Call hls.startLoad() once to resume from the current position. If that also fails within about 10 seconds, show an error state and log the failing URL from data.response or data.frag.
  • MEDIA_ERROR, fatal: the decoder or SourceBuffer rejected data. Call hls.recoverMediaError(). If a second media error arrives within 3 seconds, call hls.swapAudioCodec() and then recoverMediaError() again. A third failure means the stream is broken: call destroy and surface the error.
  • Anything else, fatal: this includes MUX_ERROR and other errors. Destroy the instance. Recovery attempts here only loop.

Retry timing is configured per request type. Since version 1.4, the config objects fragLoadPolicy, playlistLoadPolicy and keyLoadPolicy set timeouts and retry counts; older releases used flat keys such as fragLoadingMaxRetry. Keep the fragment timeout at no more than one segment duration. A 20-second timeout on 4-second segments drains the buffer before the retry fires.

CDN headers an hls player needs to stream reliably

An hls player in the browser makes cross-origin requests to the CDN, so CORS failures are the most common reason a stream works in Safari's native player but fails under hls.js. Native playback does not enforce CORS on media fetches. XHR and fetch do.

Configure these headers on the CDN for every HLS object type:

  • Access-Control-Allow-Origin on playlists, segments, init segments and AES-128 key responses. Use your player origin, or a wildcard for public content. If you send cookies through the xhrSetup hook, use the exact origin together with Access-Control-Allow-Credentials set to true.
  • Access-Control-Allow-Headers including Range, plus Access-Control-Expose-Headers with Content-Range and Content-Length, if your playlists use EXT-X-BYTERANGE.
  • Content-Type: application/vnd.apple.mpegurl for playlists, video/mp2t for TS segments, and video/mp4 for fMP4 init and media segments.
  • Cache-Control for VOD: one year with immutable on segments and VOD playlists, because those files never change after packaging.
  • Cache-Control for live media playlists: a max-age no longer than half the target duration. With 6-second segments, that means 2 to 3 seconds. Multivariant playlists can be cached for minutes.
  • Cache key for LL-HLS: blocking playlist reloads use the _HLS_msn and _HLS_part query parameters. The CDN must include them in the cache key and must not strip them.

The playlist TTL rule follows from simple arithmetic. If a live playlist is cached for a full 6-second target duration, a viewer can receive a copy that is one segment stale. hls.js then sees no new segment, waits, and refetches. In the worst case, that adds 6 seconds of latency or a stall. At half the target duration, the worst-case staleness is 3 seconds, which the default 3-segment live sync window (18 seconds at 6-second segments) absorbs.

Live HLS also creates a refresh pattern that hits the origin hard: every viewer polls the same media playlist every few seconds. Origin shield and request coalescing collapse those polls into one origin fetch per edge. BlazingCDN's HLS Streaming CDN for pre-encoded HLS and LL-HLS includes both on every plan, along with signed URLs and tokens for protecting segment paths.

Validate the hls.js setup

Run four checks before you ship. Each one has a concrete expected value.

  • CORS: in the Network panel, every playlist, segment and key response carries Access-Control-Allow-Origin, and the console shows no CORS errors.
  • Cache behavior: segment responses show a growing Age header and a cache-hit status from your CDN on the second viewer. Live media playlist Age values stay below half the target duration.
  • ABR: log hls.bandwidthEstimate and the Hls.Events.LEVEL_SWITCHED event. On a stable 20 Mbps connection, playback should reach the top rendition allowed by the player size within the first 3 to 5 segments.
  • Recovery: enable DevTools request blocking on a segment URL pattern for 15 seconds. You should see non-fatal retries, then one fatal NETWORK_ERROR, a startLoad call, and resumed playback once you unblock the pattern.

Rollback plan

Ship hls.js behind a feature flag that selects between the new player and your previous one, whether that is native playback or an older library. Keep the playlist URLs identical so the CDN cache stays warm under both paths. If startup failure rate or rebuffer ratio regresses after rollout, flip the flag and call hls.destroy() on active instances. No CDN change is needed, because the header changes above are harmless for native players.

Common hls js failure modes: symptom, cause, fix

  • Black video, manifest loads, no segments: the playlist response carries a CORS header but the segment responses do not. Apply the header rule to every path and file extension, not only to .m3u8 files.
  • Plays in Safari, fails in Chrome: Safari is using native HLS, which skips CORS checks. Fix CORS. Do not force native playback as a workaround.
  • Live stream drifts 30 seconds or more behind: the live media playlist is cached too long, or the CDN ignores origin Cache-Control. Lower the TTL to half the target duration and confirm it with the Age header.
  • QuotaExceededError after an hour: backBufferLength is left at its default of Infinity. Set it to 30 to 90 seconds.
  • Every session starts blurry: the 500 kbps default estimate is in effect. Raise abrEwmaDefaultEstimate, or persist the last hls.bandwidthEstimate in localStorage and pass it on the next load.
  • Repeated MEDIA_ERROR on one rendition: the CODECS attribute in the multivariant playlist does not match the encoded stream. Correct the playlist rather than adding more recovery attempts.

Tuning hls.js ABR and buffers once it works

How capLevelToPlayerSize cuts egress

capLevelToPlayerSize prevents hls.js from selecting renditions larger than the rendered video element. It accounts for devicePixelRatio unless ignoreDevicePixelRatio is set to true. For embedded or thumbnail-sized players, this is the single highest-value setting, because it removes bytes that viewers can never see.

Here is the worked math, using a common ladder. A 1080p rendition at 6 Mbps uses 2.7 GB per viewer-hour; a 540p rendition at 2 Mbps uses 0.9 GB. A 640-pixel inline player on a 1x display does not need more than 540p. For 10,000 viewer-hours a month in that player, the cap saves 1.8 GB per hour, or 18 TB a month, with no visible quality loss.

ABR switching factors

hls.js estimates bandwidth with fast and slow exponentially weighted moving averages. It stays at the current level while that level fits within abrBandWidthFactor (0.95) of the estimate. It only switches up when the higher level fits within abrBandWidthUpFactor (0.7). If viewers on mobile networks oscillate between levels, lower abrBandWidthUpFactor toward 0.6. If they stay at a low level for too long on fiber, raise it toward 0.8. Change one factor at a time and compare rebuffer ratio, not only average bitrate.

Buffer sizing

Forward buffer memory is roughly bitrate multiplied by maxBufferLength. At 6 Mbps, 30 seconds equals 22.5 MB, and the maxBufferSize cap of 60 MB is never reached. Leaving backBufferLength at Infinity on a 2-hour film at 6 Mbps would retain up to 5.4 GB, which the browser refuses long before that point. A 90-second back buffer retains 67.5 MB and still makes short rewinds instant.

Live latency

For standard live HLS, the target latency is about liveSyncDurationCount times the segment duration. Lowering it from 3 to 2 at 4-second segments moves playback from about 12 seconds behind the live edge to about 8. The trade-off is less margin for stalls. Set maxLiveSyncPlaybackRate to 1.1 so hls.js catches up by playing slightly faster instead of seeking. For latency of a few seconds, use LL-HLS partial segments with lowLatencyMode, and make sure the CDN honors blocking reload query parameters.

FAQ: hls.js setup and playback

Does hls.js work on iPhone Safari?

Yes, hls.js works on iPhone Safari from iOS 17.1 when you use hls.js version 1.5 or later, which supports ManagedMediaSource. On older iOS versions MSE is unavailable, so Hls.isSupported() returns false and the player should fall back to native HLS by setting the video source to the playlist URL directly. Native playback ignores hls.js ABR configuration.

Why does hls.js fail with CORS errors when native Safari playback works?

hls.js fetches playlists and segments with XHR or fetch, which enforce CORS, while native Safari HLS playback does not apply the same checks to media requests. The fix is sending Access-Control-Allow-Origin on every playlist, segment, init segment and key response from the CDN, and exposing Content-Range when byte-range segments are used.

How do I make hls.js start at a higher quality?

Raise abrEwmaDefaultEstimate, because hls.js starts from a 500 kbps bandwidth estimate and picks a matching low rendition. Setting it to around 3 Mbps, or reusing the last measured hls.bandwidthEstimate from storage, lets the first segments load at a sharper level. Forcing startLevel to a fixed high value risks slow startup on weak connections.

What Cache-Control should a CDN send for hls.js live playlists?

Live media playlists for hls.js should use a max-age of no more than half the target duration, for example 2 to 3 seconds with 6-second segments. Longer TTLs serve stale playlists, adding latency or stalls. Segments are immutable and can be cached for a year, and multivariant playlists can be cached for several minutes safely.

Test your HLS player against real CDN behavior this week

Run the four validation checks above on a production stream, not a local test file. Log bandwidthEstimate, LEVEL_SWITCHED events and fatal errors per session for one week. Then compare the startup rendition, rebuffer ratio and live latency before and after you change abrEwmaDefaultEstimate, backBufferLength and the live playlist TTL. Next, calculate how many terabytes capLevelToPlayerSize would remove from your monthly egress, using your own rendition ladder and your player sizes. Those numbers tell you which tuning change is worth shipping first.

If your segments are pre-encoded HLS or LL-HLS, BlazingCDN's Video CDN for HLS delivery replicates the library inside the CDN and removes the origin from the delivery path. A 14-day testing period on real production traffic is available for repeating these checks.

Heavy traffic.
Light bill.

The CDN for video and large traffic