DEV Community

Mason K
Mason K

Posted on

Check whether hls.js is actually using a worker (most ESM setups aren't)

TL;DR

If you import Hls from 'hls.js', you are probably getting the ESM build, and the ESM build does not bundle the transmuxer worker. Transmuxing runs on your main thread until you set workerPath. We are going to verify which mode you are in, fix it, and add a long-task observer so you can tell main-thread stalls apart from network stalls.

Two short facts before any code. hls.js 1.4 introduced the ESM build (dist/hls.mjs), and that build ships the worker as a separate file rather than inlining it. And Chromium has supported MediaSource inside dedicated workers since Chrome 108, which is a different thing that we will get to at the end. Everything here was checked against hls.js 1.7.x.

1. Find out what you are currently running ๐Ÿ”Ž

Do not guess. Run this in the console on a page where a video is playing:

// paste in DevTools console during playback
performance.getEntriesByType('resource')
  .filter(e => e.name.includes('worker'))
  .map(e => e.name);
Enter fullscreen mode Exit fullscreen mode

Empty array means no worker file was ever fetched. Then check DevTools โ†’ Sources โ†’ Threads (Chrome) or the Debugger's worker list (Firefox). If the only thread listed is the main one, hls.js is transmuxing inline.

You can also ask the library directly:

// after new Hls(...)
console.log(hls.config.workerPath);        // null if you never set it
console.log(hls.config.enableWorker);      // true by default, which is misleading
Enter fullscreen mode Exit fullscreen mode

โš ๏ธ Note: enableWorker: true is the default and it stays true even when no worker can be created. It means "use a worker if one is available", not "a worker is running". This is the single biggest source of false confidence here.

2. Wire up workerPath ๐Ÿ› ๏ธ

The pattern is: get a real URL for hls.js/dist/hls.worker.js, pass it as workerPath. The syntax differs per bundler.

Vite / Rollup:

// src/player.ts
import Hls from 'hls.js';
import workerUrl from 'hls.js/dist/hls.worker.js?url';

const hls = new Hls({
  workerPath: workerUrl,
});
Enter fullscreen mode Exit fullscreen mode

webpack 5:

// src/player.js
import Hls from 'hls.js';

const workerUrl = new URL(
  'hls.js/dist/hls.worker.js',
  import.meta.url
).toString();

const hls = new Hls({ workerPath: workerUrl });
Enter fullscreen mode Exit fullscreen mode

Next.js (app router, client component):

// app/components/Player.tsx
'use client';

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

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

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

    const hls = new Hls({
      workerPath: new URL(
        'hls.js/dist/hls.worker.js',
        import.meta.url
      ).toString(),
    });

    hls.loadSource(src);
    hls.attachMedia(video);
    return () => hls.destroy();
  }, [src]);

  return <video ref={videoRef} controls playsInline />;
}
Enter fullscreen mode Exit fullscreen mode

If you copy nothing else, copy the verification step, because a wrong path fails silently:

hls.on(Hls.Events.MANIFEST_PARSED, () => {
  const gotWorker = performance
    .getEntriesByType('resource')
    .some(e => e.name.includes('hls.worker'));
  console.log('[hls] worker active:', gotWorker);
});
Enter fullscreen mode Exit fullscreen mode
# what you want to see
[hls] worker active: true
Enter fullscreen mode Exit fullscreen mode

3. Add a long-task observer so stalls stop being ambiguous ๐Ÿ“Š

A buffer stall tells you playback ran dry. It does not tell you whether the bytes were late or the thread was busy. PerformanceObserver with longtask closes that gap: it reports every task that held the main thread for more than 50ms.

// src/instrumentation/main-thread.js
const recentLongTasks = [];

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    recentLongTasks.push({ start: entry.startTime, dur: entry.duration });
  }
  // keep the last 10 seconds only
  const cutoff = performance.now() - 10_000;
  while (recentLongTasks.length && recentLongTasks[0].start < cutoff) {
    recentLongTasks.shift();
  }
});

observer.observe({ type: 'longtask', buffered: true });

export function blockedMsInLast(windowMs = 3000) {
  const cutoff = performance.now() - windowMs;
  return recentLongTasks
    .filter(t => t.start >= cutoff)
    .reduce((sum, t) => sum + t.dur, 0);
}
Enter fullscreen mode Exit fullscreen mode

Now tag every stall with it:

// src/player.js
import { blockedMsInLast } from './instrumentation/main-thread.js';

hls.on(Hls.Events.ERROR, (_evt, data) => {
  if (data.details !== Hls.ErrorDetails.BUFFER_STALLED_ERROR) return;

  analytics.track('video_stall', {
    blocked_ms_3s: blockedMsInLast(3000),
    buffer_len: hls.media
      ? hls.media.buffered.length && hls.media.buffered.end(0) - hls.media.currentTime
      : 0,
    level: hls.currentLevel,
  });
});
Enter fullscreen mode Exit fullscreen mode
# a main-thread stall
video_stall { blocked_ms_3s: 1180, buffer_len: 0.2, level: 3 }

# a network stall
video_stall { blocked_ms_3s: 0, buffer_len: 0.1, level: 3 }
Enter fullscreen mode Exit fullscreen mode

Those two rows deserve completely different tickets. Right now most teams have them in the same bucket.

๐Ÿ’ก Tip: longtask is not supported in Safari. Feature-detect with PerformanceObserver.supportedEntryTypes.includes('longtask') and fall back to counting requestAnimationFrame gaps if you need cross-browser coverage.

4. Reproduce it on purpose ๐Ÿงช

Before you trust the numbers, prove the instrument moves. Add a deliberate main-thread hog and watch the stall rate climb:

// DO NOT SHIP. Reproduction only.
setInterval(() => {
  const end = performance.now() + 120;   // block for 120ms
  while (performance.now() < end) { /* spin */ }
}, 500);
Enter fullscreen mode Exit fullscreen mode

Run your player with that on, once with workerPath unset and once with it set. The version without a worker degrades noticeably sooner, because transmuxing is queued behind the same blocking task.

Then throttle CPU in DevTools (Performance โ†’ CPU โ†’ 4x or 6x slowdown) rather than throttling the network. Most player testing throttles the network and never touches the CPU, which is why this class of bug reaches production.

5. What the worker still does not fix

Worth being precise about, because it changes what you do next.

Stage Runs where (hls.js, worker configured)
Segment fetch Network thread
Demux + remux Worker
SourceBuffer.appendBuffer() Main thread
Decode + render Browser media pipeline

The hls.js worker is a transmuxer worker. The MediaSource still lives on the main thread, so appends still contend with your app.

The platform-level answer is MSE in a dedicated worker: construct the MediaSource inside the worker, get a handle, and pass it to the <video> element. Chromium enabled this by default in Chrome 108 (Opera 94 followed). Firefox and Safari have not shipped it. Plain main-thread MSE is universal (Firefox 42+, Safari 8+), the worker variant is not, and no mainstream HLS library drives it end to end today. So treat it as a Chromium-only enhancement you might reach for in a custom player, not as something to design your architecture around.

Related config worth knowing while you are in here: preferManagedMediaSource (added in hls.js 1.5.0) controls whether hls.js picks ManagedMediaSource over MediaSource on platforms exposing both. Set it to false if you specifically want the classic path.

What's next

Two follow-ups, in the order I would do them.

  1. Ship the long-task tag behind a sampling flag (1% of sessions is plenty) and look at the split after a week. If a meaningful share of your stalls have blocked time in front of them, your next video-quality win is a front-end performance ticket, not a CDN ticket.
  2. Go after the biggest offender on the page. Usually a list rendering under the player, an unvirtualised feed, or a JSON parse on the response of whatever loads next. Moving that to a worker helps video even though it has nothing to do with video.

If you want to go deeper on the platform side, the hls.js API docs cover workerPath and enableWorker in full, and the MDN Media Source Extensions page has the current worker-support picture. And if you find that your stalls are genuinely network-side after all, that is a good outcome too: you just stopped guessing.

Top comments (0)