---
title: "HLS.js Demo: Build a Working Video Player"
description: "Build an HLS.js demo with native Safari fallback, error recovery, CORS checks, React cleanup, and a production-ready video playback test plan for launch."
url: https://www.zerobuffer.io/blogs/hls-js-demo
date_published: 2026-08-06
date_modified: 2026-08-06
topic: "C4 — HLS Playback & Players"
keywords: ["hls js demo","js hls"]
word_count: 2448
author: "Sahil Asopa"
author_url: https://www.zerobuffer.io/authors/sahil-asopa
author_profiles: ["https://www.linkedin.com/in/sahilasopa/","https://github.com/sahilasopa"]
publisher: ZeroBuffer
license: © 2026 Apexnova Private Limited
---
![Developer workstation showing adaptive HLS streams flowing into a browser player](https://www.zerobuffer.io/blogs/hls-js-demo/thumbnail.png)

# HLS.js Demo: Build a Working Video Player

An HLS stream that plays in VLC can still fail the moment you put it in a browser. The quickest way to separate a bad manifest from a bad player integration is to build a small **HLS.js demo** that exposes each playback step instead of hiding everything behind a framework.

This guide gives you a complete vanilla JavaScript player, native HLS fallback for Safari, visible status messages, fatal-error recovery, a React version with correct cleanup, and a test plan you can use before shipping.

## What does an HLS.js demo prove?

An HLS.js demo proves that a browser can fetch your `.m3u8` manifest, parse its renditions, load compatible media segments, and append them to an HTML `<video>` element through Media Source Extensions. A successful demo also confirms that cross-origin rules, TLS, codecs, and the basic playback lifecycle work in the browser you are testing.

It does not prove that every viewer will get a good experience. Production readiness also depends on cross-browser coverage, startup time, buffering, adaptive bitrate behavior, signed URL lifetime, captions, analytics, and CDN performance.

HLS.js is a playback engine rather than a complete player interface. It handles HLS loading and adaptive playback while the browser's video element supplies standard controls; the [official HLS.js project](https://github.com/video-dev/hls.js/) documents compatibility with browsers that expose Media Source Extensions and a supported `video/MP4` input.

## Before you build the HLS.js demo

Start with a direct HLS manifest URL. A watch-page URL, an MP4, or an API endpoint that returns JSON is not interchangeable with a `.m3u8` playlist.

Check these five inputs first:

1. **A testable manifest:** Use a stream you own or a public sample intended for development. Apple's [HLS example streams](https://developer.apple.com/streaming/examples/) cover multiple codecs, captions, and advanced features, although not every codec is supported on every device.
2. **HTTPS from end to end:** An HTTPS page should not load an HTTP manifest, segment, key, or caption file. Mixed-content blocking can look like a player failure.
3. **Consistent CORS headers:** The manifest, child playlists, segments, encryption keys, and subtitle files may live at different URLs. Every cross-origin response used by JavaScript needs an appropriate policy.
4. **Browser-decodable codecs:** HLS.js can parse a playlist but cannot make the device decode an unsupported codec combination.
5. **No accidental authorization expiry:** A signed master URL is not enough if the segment or key URLs expire before the viewer requests them.

For the first pass, use the public Mux test manifest already referenced by HLS.js examples. Once the demo works, replace it with your own URL and compare the network traces.

## HLS.js demo: copy-paste HTML player

Save the following as `index.html`, serve it from a local web server, and open it in a modern browser. It uses the major-version HLS.js CDN URL shown in the project's [official API usage guide](https://github.com/video-dev/hls.js/blob/master/docs/API.md), so the page does not require a build step.

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>HLS.js demo</title>
    <style>
      body {
        max-width: 960px;
        margin: 40px auto;
        padding: 0 20px;
        color: #e5eefc;
        background: #07111f;
        font: 16px/1.5 system-ui, sans-serif;
      }
      video { width: 100%; aspect-ratio: 16 / 9; background: #000; }
      #status { min-height: 1.5em; color: #8fd3ff; }
      #status[data-error="true"] { color: #ff9b9b; }
    </style>
  </head>
  <body>
    <h1>HLS.js demo player</h1>
    <video id="video" controls playsinline preload="metadata"></video>
    <p id="status" role="status" aria-live="polite">Starting player…</p>

    <script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
    <script>
      const source =
        'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8';
      const video = document.querySelector('#video');
      const status = document.querySelector('#status');

      function setStatus(message, isError = false) {
        status.textContent = message;
        status.dataset.error = String(isError);
      }

      function requestPlayback() {
        video.play().catch(() => {
          setStatus('Ready. Press play to start; autoplay was blocked.');
        });
      }

      if (Hls.isSupported()) {
        const hls = new Hls({
          enableWorker: true,
          capLevelToPlayerSize: true,
        });

        hls.loadSource(source);
        hls.attachMedia(video);

        hls.on(Hls.Events.MANIFEST_PARSED, (_event, data) => {
          setStatus(`Manifest parsed: ${data.levels.length} quality levels.`);
          requestPlayback();
        });

        hls.on(Hls.Events.LEVEL_SWITCHED, (_event, data) => {
          const level = hls.levels[data.level];
          if (level?.height) {
            setStatus(`Playing ${level.height}p in automatic quality mode.`);
          }
        });

        hls.on(Hls.Events.ERROR, (_event, data) => {
          console.error('HLS.js error', data);
          if (!data.fatal) return;

          if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
            setStatus('Fatal network error. Retrying…', true);
            hls.startLoad();
          } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
            setStatus('Fatal media error. Attempting recovery…', true);
            hls.recoverMediaError();
          } else {
            setStatus(`Unrecoverable error: ${data.details}`, true);
            hls.destroy();
          }
        });

        window.addEventListener('pagehide', () => hls.destroy(), { once: true });
      } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
        video.src = source;
        video.addEventListener('loadedmetadata', () => {
          setStatus('Loaded with the browser\'s native HLS engine.');
          requestPlayback();
        });
      } else {
        setStatus('This browser supports neither HLS.js nor native HLS.', true);
      }
    </script>
  </body>
</html>
```

Run it through a server instead of double-clicking the file:

```bash
npx serve .
```

Open the localhost URL printed by the command. If the manifest parses, the status line shows the number of quality levels and then reports automatic rendition switches.

## How the HLS.js demo works

The demo has two playback paths because support is a runtime question, not a user-agent question.

### 1. Detect the engine before loading the stream

`Hls.isSupported()` checks whether HLS.js can use the browser's media pipeline. When it returns `false`, `video.canPlayType('application/vnd.apple.mpegurl')` checks for native HLS. This fallback matters on Apple platforms, where direct HLS playback may be the appropriate path even when the JavaScript engine is unavailable or unnecessary.

If both checks fail, changing the manifest URL will not solve the problem. You need a supported browser, another delivery format, or a player that supports the target environment.

### 2. Load the manifest and attach the video element

`loadSource()` gives HLS.js the master or media playlist URL. `attachMedia()` connects the HLS.js instance to the `<video>` element and creates the media-source plumbing used to append playable buffers.

The `MANIFEST_PARSED` event is the first useful success checkpoint. It means the top-level playlist was fetched and understood, but playback can still fail later on a variant playlist, segment, encryption key, or decoder.

### 3. Let adaptive bitrate start in automatic mode

The example does not force `currentLevel`. HLS.js can therefore choose a rendition based on its bandwidth estimate and buffer state. `capLevelToPlayerSize: true` prevents automatic selection from needlessly exceeding the displayed player size, while the `LEVEL_SWITCHED` listener makes the selected resolution visible.

Keep manual quality selection out of the first demo. It adds UI state before you have proved that normal automatic playback works.

### 4. Separate expected errors from terminal failures

HLS playback involves repeated network requests, so a recoverable retry should not page an engineer. The [HLS.js API guide](https://github.com/video-dev/hls.js/blob/master/docs/API.md) distinguishes fatal errors and documents `startLoad()` for network recovery and `recoverMediaError()` for media recovery.

The demo logs the complete event object for diagnosis but changes the UI only for fatal failures. A production player should also cap recovery attempts; endlessly retrying an expired URL or invalid manifest wastes bandwidth and hides the real fault.

![HLS playback pipeline with manifest checks, adaptive streams, and a blocked request path](https://www.zerobuffer.io/blogs/hls-js-demo/mid-article.png)

## Turn the HLS.js demo into a production-ready player

A green demo is the beginning of integration, not the end. Harden the boundaries around the player before adding custom controls.

### Pin dependencies and control upgrades

The sample pins the HLS.js major version with `@1`, following the official usage pattern. For a production build, install `hls.js` from npm, commit your lockfile, and upgrade deliberately. Test startup, seeking, captions, encrypted content, and error recovery against the same manifest set on every change.

Avoid `@latest` in a deployed script tag. A release should not change your playback engine without passing through your test pipeline.

### Treat autoplay as optional

`video.play()` returns a promise and may reject when playback with sound lacks user interaction. The demo catches that rejection and leaves native controls available instead of presenting it as a stream error.

If autoplay is a product requirement, design for muted playback or a clear play action. MDN's [autoplay guide](https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Autoplay) recommends handling policies that may allow, block, or allow only inaudible media.

### Fix CORS at the delivery layer

HLS.js fetches resources under browser security rules. MDN describes [CORS as an HTTP-header mechanism](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS) that lets a server authorize other origins; JavaScript cannot grant itself that permission.

Apply the policy to the master manifest, every variant manifest, `.ts` or `.m4s` segments, keys, captions, and error responses. When cookies or authorization headers are involved, use an explicit allowed origin and credential-aware configuration rather than a wildcard.

### Make the delivery path observable

Record player version, stream identifier, startup time, fatal error type and detail, selected level, rebuffer events, and the CDN request ID when available. Do not log signed URLs or viewer credentials.

Once playback logic is stable, segment delivery becomes a separate systems problem. [ZeroBuffer's OTT CDN](https://www.zerobuffer.io/cdn-for-ott) is designed to cache HLS manifests and media segments at the edge, with origin shielding and [published pay-as-you-go pricing](https://www.zerobuffer.io/pricing); it is a practical delivery layer to test when global egress or origin load—not JavaScript—is the constraint.

### Destroy the instance when its owner disappears

Single-page apps can leave listeners, workers, requests, and media buffers alive after navigation if the HLS.js instance is never destroyed. Give each mounted player one instance, destroy it during cleanup, and create a fresh one when the source changes.

## Use HLS.js in React without leaking an instance

The playback logic is the same in React, but ownership must follow the component lifecycle:

```jsx
import { useEffect, useRef, useState } from 'react';
import Hls from 'hls.js';

export function HlsPlayer({ src, poster }) {
  const videoRef = useRef(null);
  const [error, setError] = useState('');

  useEffect(() => {
    const video = videoRef.current;
    if (!video || !src) return;

    setError('');

    if (Hls.isSupported()) {
      const hls = new Hls({ capLevelToPlayerSize: true });
      hls.loadSource(src);
      hls.attachMedia(video);

      hls.on(Hls.Events.ERROR, (_event, data) => {
        if (!data.fatal) return;
        setError(`Playback failed: ${data.details}`);
      });

      return () => hls.destroy();
    }

    if (video.canPlayType('application/vnd.apple.mpegurl')) {
      video.src = src;
      return () => {
        video.removeAttribute('src');
        video.load();
      };
    }

    setError('HLS playback is not supported in this browser.');
  }, [src]);

  return (
    <div>
      <video ref={videoRef} controls playsInline poster={poster} />
      {error && <p role="alert">{error}</p>}
    </div>
  );
}
```

The cleanup function is the important part. It runs before the effect is recreated for a new `src` and when the component unmounts, preventing the previous stream from continuing in the background.

If you want a ready-made UI rather than a custom video element, compare a wrapper such as the [Video.js HlsJsVideo component](https://videojs.org/docs/framework/react/reference/hlsjs-video). A wrapper can reduce control and accessibility work, while direct HLS.js integration gives you tighter access to events and configuration.

## Troubleshoot a broken HLS.js demo

Start with the earliest failed request or event. Later errors are often consequences.

| Symptom | Likely cause | First check |
|---|---|---|
| No manifest request appears | Script did not load or initialization did not run | Console errors, Content Security Policy, and the HLS.js script response |
| Manifest request is blocked | CORS, mixed content, DNS, or TLS | Browser console and response headers on the exact `.m3u8` URL |
| Master loads but a variant fails | Broken relative URL, expired signature, or inconsistent CORS | First failing child playlist in the Network panel |
| Segments return 200 but playback fails | Unsupported codec, malformed media, or timestamp discontinuity | `data.details`, codec strings, and the same stream in the official demo |
| `video.play()` rejects | Autoplay policy | Keep controls visible; retry after a user gesture |
| Playback works in Safari but HLS.js events never fire | Native HLS fallback is active | Confirm which support branch ran |
| Live playback drifts or stalls | Stale playlists, cache policy, encoder cadence, or insufficient throughput | Playlist age, target duration, buffer, and segment timing |
| Old stream keeps downloading in React | Instance cleanup is missing | Verify `hls.destroy()` runs on source change and unmount |

Use the project's [official HLS.js demo](https://hlsjs.video-dev.org/demo/) as a control. If your stream fails there and in your page at the same request, investigate packaging or delivery. If it succeeds there but fails in your page, compare HLS.js versions, configuration, request credentials, CSP, and application lifecycle.

## Test the player before release

Use a small matrix instead of relying on the browser open on your laptop:

- Run one known-good VOD stream, one live stream, and one intentionally invalid URL.
- Test Chrome, Firefox, Edge, macOS Safari, and a real iPhone or iPad relevant to your audience.
- Confirm the native fallback path as well as the HLS.js path.
- Throttle the network and watch automatic quality changes and buffer recovery.
- Seek near the start, middle, and end of VOD; for live, seek within the DVR window and return to the live edge.
- Verify captions, alternate audio, poster, fullscreen, picture-in-picture, and keyboard operation when your product exposes them.
- Expire a signed URL on purpose and confirm the viewer gets an actionable message rather than an infinite spinner.
- Navigate away or change sources and confirm network requests from the old instance stop.

The result should be repeatable: a known stream passes, a broken stream fails visibly, and the logs identify which layer failed without exposing secrets.

## Frequently asked questions

### Can HLS.js play an M3U8 file?

Yes. HLS.js loads `.m3u8` HLS playlists in browsers that provide a compatible Media Source Extensions pipeline. The media codecs listed by the playlist must still be decodable on the device.

### Does Safari need HLS.js?

Not always. Safari supports native HLS, so a common implementation assigns the manifest directly to `video.src` when the HLS.js support check is false. Keep both feature-detected paths because support varies by platform and browser version.

### Why does my HLS stream work in VLC but not in the browser?

Browsers enforce CORS, mixed-content, autoplay, and codec rules that a desktop player may handle differently. Inspect the first failed manifest, segment, key, or subtitle request in browser developer tools before changing HLS.js settings.

### How do I know the HLS.js demo is working?

Confirm that `MANIFEST_PARSED` fires, video frames render, audio plays when expected, and quality levels switch under throttling. Also test a deliberate failure so you know the status and recovery paths work.

### Can I use HLS.js with React or Next.js?

Yes. Create the HLS.js instance in a client-side effect after the video element exists, and destroy it in the effect cleanup whenever the source changes or the component unmounts.

## Build the smallest player that proves the whole path

Choose direct HLS.js when you need a custom interface, detailed playback events, or precise control over adaptive streaming. Choose a mature player wrapper when accessible controls, plugins, and faster UI delivery matter more than owning the engine integration.

Either way, keep this demo as a diagnostic fixture. Replace the sample with one production manifest, run the browser matrix, and measure startup, rebuffering, and fatal errors before you tune advanced options or move real traffic.
