DEV Community

Mason K
Mason K

Posted on

Build a React video player hook that actually releases the player (and a Playwright test that proves it)

TL;DR

Single-page apps leak video players on every route change unless three things are torn down in the right order: the hls.js instance, the <video> element's decoder, and your own references. We'll build a useHlsPlayer hook that does it correctly, then a Playwright test that mounts the player 100 times and fails CI if Chrome's "too many WebMediaPlayers" error ever appears.

What we're building

A React hook, useHlsPlayer, that owns the whole player lifecycle, and an end-to-end test that catches the regression when someone deletes the cleanup. Stack: React 19, hls.js 1.7, Vite, Playwright. Node 22.

Why this matters: Chrome counts your players

Since around Chrome 92, Chromium enforces a cap on media players per frame: 75 on desktop, 40 on mobile. Cross it and the next <video> you try to play logs this and refuses:

Blocked attempt to create a WebMediaPlayer as there are too many WebMediaPlayers already in existence.
Enter fullscreen mode Exit fullscreen mode

The Chromium team picked those numbers as the 99.9th percentile of what real pages create, specifically to stop infinite-scroll video sites from holding thousands of players in memory. If your app can reach the cap, it is leaking players. Let's make sure it can't.

1. Set up the project

npm create vite@latest react-hls-teardown -- --template react-ts
cd react-hls-teardown
npm install hls.js@^1.7
npm install -D @playwright/test
npx playwright install chromium
Enter fullscreen mode Exit fullscreen mode

Add a test stream. Any public HLS URL works; the hls.js demo streams are fine for a leak test since we only care about player creation, not content.

2. The naive hook (this is what leaks)

Here is the version that shows up in most codebases. It works on first mount and leaks on every remount.

// src/useHlsPlayerNaive.ts  (DO NOT SHIP)
import { useEffect, useRef } from "react";
import Hls from "hls.js";

export function useHlsPlayerNaive(src: string) {
  const videoRef = useRef<HTMLVideoElement>(null);

  useEffect(() => {
    const video = videoRef.current!;
    const hls = new Hls();
    hls.loadSource(src);
    hls.attachMedia(video);
    // no cleanup returned
  }, [src]);

  return videoRef;
}
Enter fullscreen mode Exit fullscreen mode

Every time src changes or the component remounts, a new Hls instance is created and the old one is still alive, still attached, still holding a MediaSource. Remove the <video> from the DOM and the browser-side player is still there too, because nothing told it to release.

3. The hook with correct teardown

Three layers, torn down in this order.

// src/useHlsPlayer.ts
import { useEffect, useRef } from "react";
import Hls from "hls.js";

export function useHlsPlayer(src: string) {
  const videoRef = useRef<HTMLVideoElement>(null);

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

    let hls: Hls | null = null;

    if (Hls.isSupported()) {
      hls = new Hls({ enableWorker: true });
      hls.loadSource(src);
      hls.attachMedia(video);
    } else if (video.canPlayType("application/vnd.apple.mpegurl")) {
      video.src = src; // Safari native HLS
    }

    return () => {
      // 1. Library first. destroy() detaches media and removes listeners
      //    internally, in that order. Do not call removeAllListeners() before it.
      hls?.destroy();
      hls = null;

      // 2. Browser-side player. Removing src alone does nothing per spec;
      //    load() is what resets the element and releases the decoder.
      video.pause();
      video.removeAttribute("src");
      video.load();
    };
  }, [src]);

  return videoRef;
}
Enter fullscreen mode Exit fullscreen mode

⚠️ Note: there is a maintainer-confirmed footgun here. If you call hls.removeAllListeners() before hls.destroy(), destroy cannot complete, because hls.js's internal components listen for the DESTROYING event to clean themselves up. You end up with a half-dead instance that still ticks on media events and throws Cannot read properties of null (reading 'getAppendedFrag') on the next route. Let destroy() do the listener removal itself.

💡 Tip: if you ever set video.src to a blob or object URL, add URL.revokeObjectURL(url) to the cleanup. Clearing src does not free the Blob.

4. The player component

// src/Player.tsx
import { useHlsPlayer } from "./useHlsPlayer";

export function Player({ src }: { src: string }) {
  const videoRef = useHlsPlayer(src);
  return <video ref={videoRef} controls muted playsInline style={{ width: 480 }} />;
}
Enter fullscreen mode Exit fullscreen mode

And a tiny app that mounts and unmounts it on demand, so the test can drive it. In a real app this is your router; here we keep it explicit.

// src/App.tsx
import { useState } from "react";
import { Player } from "./Player";

const SRC = "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8";

export default function App() {
  const [mounted, setMounted] = useState(true);
  const [naive, setNaive] = useState(false);
  return (
    <main>
      <button id="toggle" onClick={() => setMounted((m) => !m)}>toggle player</button>
      <label><input type="checkbox" onChange={(e) => setNaive(e.target.checked)} /> use naive hook</label>
      {mounted && <Player src={SRC} key={naive ? "naive" : "safe"} />}
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

(Wire naive through to useHlsPlayerNaive in your own copy if you want to watch the failing case; I left it out of the snippet to keep Player honest.)

5. Verify by hand first

Run the dev server, open chrome://media-internals in a second tab, and click toggle player twenty times.

npm run dev
# Local: http://localhost:5173/
Enter fullscreen mode Exit fullscreen mode

With the safe hook, the players list in media-internals grows by one and shrinks by one on each toggle. With the naive hook, it only grows. On the 76th mount you get the blocked-player error in the console.

You can also open DevTools, Memory panel, and record a Detached elements profile. Detached <video> nodes retained by JavaScript are exactly what the naive hook produces.

6. The Playwright leak test

Now let's make CI catch it. Two assertions: Chrome never logged the blocked-player error, and after 100 mount/unmount cycles a fresh player can still load metadata (which it cannot once the cap is hit).

// tests/player-leak.spec.ts
import { test, expect } from "@playwright/test";

const BLOCKED = /Blocked attempt to create a WebMediaPlayer/;

test("mounting the player 100 times does not leak players", async ({ page }) => {
  const consoleErrors: string[] = [];
  page.on("console", (msg) => {
    if (msg.type() === "error" || msg.type() === "warning") consoleErrors.push(msg.text());
  });

  await page.goto("/");
  const toggle = page.locator("#toggle");

  for (let i = 0; i < 100; i++) {
    await toggle.click(); // unmount
    await toggle.click(); // mount
    await page.locator("video").waitFor({ state: "attached" });
  }

  // 1. Chrome never refused to create a player.
  expect(consoleErrors.filter((t) => BLOCKED.test(t))).toHaveLength(0);

  // 2. The 101st player is a real player: it reaches HAVE_METADATA.
  await expect
    .poll(() => page.evaluate(() => document.querySelector("video")!.readyState), { timeout: 15_000 })
    .toBeGreaterThanOrEqual(1);
});
Enter fullscreen mode Exit fullscreen mode

The console assertion is the teeth, because the Chromium cap turns a slow leak into a deterministic error string. The readyState poll is the belt to go with the braces: a blocked player never gets metadata. With the naive hook this test fails around the 76th mount. With the safe hook it passes in under a minute.

💡 Tip: if you want to see what is leaking rather than that it leaks, open the same page in Chrome, run twenty toggles by hand, and record a Detached elements profile in the Memory panel. It lists the <video> nodes that left the DOM but are still retained by JavaScript, with the retaining path.

npx playwright test
# Running 1 test using 1 worker
#   ✓ tests/player-leak.spec.ts:6:1 › mounting the player 100 times does not leak players (41.2s)
# 1 passed (42s)
Enter fullscreen mode Exit fullscreen mode

Playwright config only needs webServer pointed at Vite and use.baseURL set to the dev server.

// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
  webServer: { command: "npm run dev", url: "http://localhost:5173", reuseExistingServer: true },
  use: { baseURL: "http://localhost:5173", headless: true },
});
Enter fullscreen mode Exit fullscreen mode

⚠️ Note: run this in Chromium only. The WebMediaPlayer cap is a Chromium behaviour; Firefox and WebKit will not emit the error, so the test would pass there even with a leak.

7. Things to know

  • Route transitions are the common trigger, not tab switches. Any place React unmounts and remounts the component (route change, modal, list re-key, Suspense boundary) creates a new player. If the old one isn't released, the count only goes up.
  • Feeds are the worst case. A virtualised list that recycles rows mounts and unmounts players by the dozen. The safe hook is mandatory there, and you should also cap how many players exist at once by only mounting <video> for rows near the viewport.
  • Safari native HLS has the same teardown. No hls.js instance, but you still need pause(), removeAttribute("src"), load().
  • Android WebView enforces the mobile cap (40). If your app ships inside a WebView, you hit it faster.
  • Don't reuse one <video> across hls.js instances without destroy() first. The library's own issue tracker has the exact error you'll get if you skip it.

What's next

  • Add a second test that mounts five players simultaneously and asserts they are all released, to cover carousels and grids.
  • If you're on Shaka or dash.js, the shape is the same: player.destroy() returns a promise, so await it in an async cleanup before you reset the element.
  • Read the hls.js API docs on destroy() and the Hls.Events.DESTROYING event if you want to hook your own cleanup into the library's, rather than running it alongside.

If you're posting about this on #webdev or #react, the one-line version is: a route change is not a page load, and nothing frees your player for you.

Top comments (0)