September 16, 2026

How to play HLS in React with hls.js

Santhosh Lingabalan
Santhosh Lingabalan
Content & Brand Marketer - GTM

Install hls.js, wire it into a useEffect, and an HLS stream plays almost everywhere on the first try. Then someone opens the same page on an older iPhone and gets a black rectangle with nothing in the console. Hls.isSupported() returned false, the effect bailed out early, and nothing ever set a source on the video element. The library isn't broken. That device has no Media Source Extensions, so the only path that works is the native one, and a React HLS player has to carry both from the start.

TL;DR

Check Hls.isSupported() first and use hls.js whenever it's true, which is every current desktop browser and iOS 17.1 and later. Fall back to video.canPlayType and a plain video.src only when it's false, which today means iPhone Safari before 17.1. Checking native support first sends Chrome down the native branch, because Chrome now reports HLS support too, and then no hls.js instance exists for anything later in your component to use. Call destroy() on cleanup or a remount leaks the instance. Handle Hls.Events.ERROR and branch on data.fatal.

Why React needs a library for HLS in the first place

An HLS stream isn't a file. It's an .m3u8 manifest listing several renditions of the same video, each split into segments of a few seconds. Something has to read that list, choose a rendition, fetch the segments, and hand them to the video element through Media Source Extensions.

Safari and iOS have always implemented all of that in the browser, and desktop Chrome has since version 142, so <video src="stream.m3u8" /> works there. Firefox doesn't, and native playback gives you no control over rendition switching or analytics anywhere. That's why hls.js exists. It's an HLS client in JavaScript that appends segments to a SourceBuffer on your behalf.

BrowserNative HLSHls.isSupported()Path your code takes
Chrome, EdgeYes, since Chrome 142trueAttach an hls.js instance
FirefoxNotrueAttach an hls.js instance
macOS Safari, iPadOSYestrueAttach an hls.js instance
iPhone Safari, iOS 17.1 and laterYestrueAttach an hls.js instance
iPhone Safari before iOS 17.1YesfalseSet video.src directly
Older or unsupportedNofalseNeither works, show a fallback

Two rows in there surprise people. Hls.isSupported() is true on macOS Safari, because Safari has had Media Source Extensions for years. And desktop Chrome plays HLS natively now, so a native-first check sends Chrome down the native branch and no hls.js instance is ever created.

Both checks can fail, and code that assumes one of them is true renders a silent black box.

What you need before you start

  • A React 18 or later app, and npm install hls.js. Version 1.7.3 is current and Apache-2.0 licensed.
  • An HLS manifest URL to test against. A FastPix playback ID gives you one at https://stream.fastpix.com/{playbackId}.m3u8.
  • A FastPix account if you want a stream of your own, from activate your account.

Step 1: Write both code paths

Check Hls.isSupported() first. This is the order hls.js documents, and the order the FastPix player uses internally:

text
import { useEffect, useRef, useState } from "react";
import Hls from "hls.js";

export default function HlsPlayer({ src }) {
  const videoRef = useRef(null);
  const [unsupported, setUnsupported] = useState(false);

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

    if (Hls.isSupported()) {
      const hls = new Hls();
      hls.loadSource(src);
      hls.attachMedia(video);
      return () => hls.destroy();
    }

    if (video.canPlayType("application/vnd.apple.mpegurl")) {
      video.src = src;
      return;
    }

    setUnsupported(true);
  }, [src]);

  if (unsupported) return <p>This browser cannot play HLS.</p>;

  return <video ref={videoRef} controls playsInline style={{ width: "100%" }} />;
}

Order the checks that way round. Reversing them looks reasonable, on the theory that a browser with native HLS should use it, and it breaks Chrome: canPlayType("application/vnd.apple.mpegurl") returns "maybe" there now, so Chrome takes the native branch and never gets an hls.js instance. Everything the rest of this article builds on that instance, the error recovery, the quality menu, the analytics hookup, then silently does nothing in the most common browser.

If you do want the native path where it exists, gate it the way hls.js documents, on "ManagedMediaSource" in window rather than on canPlayType alone.

Step 2: Destroy the instance, or React 18 will punish you

That return () => hls.destroy() isn't defensive coding. React 18 StrictMode mounts every effect, unmounts it, and mounts it again in development. An effect without cleanup leaves two hls.js instances attached to one video element.

The symptom is specific and confusing: playback stutters, the console fills with buffer append errors, and it only happens in development. Two instances are appending different segments to the same SourceBuffer, and the buffer rejects the overlap.

destroy() detaches the media, aborts in-flight segment requests and releases the buffer. Call it on every path that replaces the source, not only on unmount, which is what the [src] dependency array above takes care of.

Step 3: Recover from the errors worth recovering from

hls.js reports errors through a single event, and the important field is data.fatal. Non-fatal errors are routine, a segment 404s and the next one is fine, so handling them individually is noise. Hls.ErrorTypes has five members. Two of them have a standard recovery, and everything else is terminal:

text
hls.on(Hls.Events.ERROR, (_event, data) => {
  if (!data.fatal) return;

  switch (data.type) {
    case Hls.ErrorTypes.NETWORK_ERROR:
      hls.startLoad();          // retry the manifest or segment
      break;
    case Hls.ErrorTypes.MEDIA_ERROR:
      hls.recoverMediaError();  // re-create the SourceBuffer
      break;
    default:
      hls.destroy();            // unrecoverable, tear down and report
      setPlaybackFailed(true);
  }
});

You can test this against a real adaptive stream first. The free plan covers 10 videos and 100K streaming minutes a month with no credit card.

Calling recoverMediaError() in a loop is the trap. If the second recovery attempt fails within a few seconds of the first, stop and surface the failure. Retrying forever turns a broken stream into a spinner that never resolves. That default branch is the only signal you'll ever get that a viewer couldn't watch. Which is the argument for monitoring playback errors rather than logging them to a console nobody reads.

Step 4: Expose the quality levels

hls.js picks a rendition automatically and will keep doing so. Viewers on metered connections still want the choice, and the levels only exist after the manifest parses:

text
hls.on(Hls.Events.MANIFEST_PARSED, (_event, data) => {
  setLevels(data.levels.map((level, index) => ({
    index,
    label: `${level.height}p`,
  })));
});

// -1 restores automatic bitrate switching
const pick = (index) => { hls.currentLevel = index; };

Setting currentLevel locks the rendition. Setting it to -1 hands control back to the adaptive algorithm. Offer that as an "Auto" option: most viewers who open a quality menu are trying to undo a bad manual choice, not make one.

Where hls.js stops

Everything above is playback. None of it produces the stream, and that asymmetry catches teams out: the player work takes an afternoon, and the thing feeding it takes considerably longer.

Something has to transcode the source into a rendition ladder and cut segments on aligned keyframes, so the player can switch cleanly. Then write the manifest and put the segments behind a CDN. Misaligned keyframes are the reason a stream switches renditions with a visible stall, and no amount of player configuration fixes it. Reducing latency in HLS streaming covers the segment-duration side of that, and the guide to m3u8 files covers the manifest itself.

With FastPix the manifest URL is the playback ID. The ladder, segmentation and delivery are already done:

text
const src = `https://stream.fastpix.com/${playbackId}.m3u8`;

The other thing hls.js doesn't do is tell you how playback went. Attach the FastPix Data SDK to the same instance and it collects startup time, rebuffering and errors per session. It takes the instance and the constructor together:

text
import fastpixMetrix from "@fastpix/video-data-core";

fastpixMetrix.tracker(video, {
  hlsjs: hls,
  Hls,
  data: {
    workspace_id: "WORKSPACE_KEY",
    player_name: "Main Player",
    player_init_time: fastpixMetrix.utilityMethods.now(),
  },
});

Passing both hlsjs and Hls is required. Leaving out the constructor is the most common setup mistake. The full attribute list, including disableCookies and respectDoNotTrack, is in the hls.js monitor guide.

Stream HLS in your React app with FastPix

Upload one video, take the playback ID from the video.media.ready webhook, and point the src in Step 1 at https://stream.fastpix.com/{playbackId}.m3u8. The ladder, keyframe alignment and CDN delivery are already done, so the only code you write is the component above. The free plan covers 10 videos and 100K streaming minutes a month, no credit card. That's enough to run a real stream through both the Safari and the Media Source Extensions path.

Frequently Asked Questions (FAQs)

How do I play an m3u8 file in React?

Check Hls.isSupported() first. Create an hls.js instance when it is true, call loadSource() and attachMedia(), and return hls.destroy() from the effect cleanup. Fall back to video.canPlayType("application/vnd.apple.mpegurl") and a direct video.src when it is false. Both checks can fail on older browsers, so render a fallback for that case.

Does hls.js work in Safari?

Yes. Hls.isSupported() is true on macOS Safari, on iPadOS 13 and later, and on iPhone Safari from iOS 17.1, because all of them have Media Source Extensions. It is false only on iPhones before 17.1, and that is the case the native video.src branch exists for.

Why does my React HLS player stutter only in development?

React 18 StrictMode mounts effects twice in development. An effect without cleanup creates two hls.js instances appending segments to the same SourceBuffer. The overlap produces buffer append errors and visible stutter that disappears in a production build. Returning () => hls.destroy() from the effect fixes it.

How do I handle hls.js errors in React?

Listen to Hls.Events.ERROR and ignore anything where data.fatal is false, since non-fatal errors are routine. For fatal errors, call hls.startLoad() on NETWORK_ERROR and hls.recoverMediaError() on MEDIA_ERROR. Anything else is unrecoverable: destroy the instance, show a failure state and report it rather than retrying in a loop.

Can I let viewers choose video quality with hls.js?

Yes. Read data.levels in the MANIFEST_PARSED event to build the menu, then set hls.currentLevel to a level index to lock the rendition. Setting currentLevel to -1 restores automatic bitrate switching, and offering that as an "Auto" option matters more than the individual resolutions.

Share

Stay Ahead of Video
Streaming Trends

Start shipping video today.