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 auseHlsPlayerhook 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.
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
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;
}
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;
}
⚠️ Note: there is a maintainer-confirmed footgun here. If you call
hls.removeAllListeners()beforehls.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 throwsCannot read properties of null (reading 'getAppendedFrag')on the next route. Letdestroy()do the listener removal itself.💡 Tip: if you ever set
video.srcto a blob or object URL, addURL.revokeObjectURL(url)to the cleanup. Clearingsrcdoes 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 }} />;
}
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>
);
}
(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/
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);
});
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)
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 },
});
⚠️ 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 withoutdestroy()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, soawaitit in an async cleanup before you reset the element. - Read the hls.js API docs on
destroy()and theHls.Events.DESTROYINGevent 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)