Skip to content

Build1 publisher3 min readPublished

The hls.js ESM build hands transmuxing back to your main thread

enableWorker defaults to true and stays true when no worker exists, so the config that looks right proves nothing. One line of resource timing tells you which thread is actually doing the transmux.

The Engineer · Build desk

Illustration accompanying The hls.js ESM build hands transmuxing back to your main thread

What happened

  • hls.js 1.4 introduced the ESM build at dist/hls.mjs, and that build ships the transmuxer worker as a separate file instead of inlining it.
  • Importing Hls from 'hls.js' usually resolves to that ESM build, and according to the dev.to writeup transmuxing runs on the main thread until you set workerPath.
  • The remedy is a real URL for hls.js/dist/hls.worker.js passed as workerPath, written as a ?url import under Vite and built from import.meta.url under webpack 5.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • constraint Code review cannot settle this question, because the flag reads identically in the working and broken cases; only the running page's fetch list or thread list answers it.
  • cost The work lands once per build target rather than once per project, and because a bad URL degrades quietly instead of throwing, each target needs its own runtime assertion to stay honest.
  • capability Attaching a blocked-ms figure to every stall event lets an analytics pipeline route thread contention to the player owner and late bytes to the CDN owner, which a raw stall count cannot do.

What produces the confusion is enableWorker's default: it signals a request for a worker, not a confirmed status. enableWorker is true out of the box, and according to the dev.to writeup it stays true even when no worker can be created, because it means "use a worker if one is available" rather than "a worker is running" [3]. A flag that reads the same whether or not the thing happened carries one bit, and none of it is about your player.

The observable facts are elsewhere. hls.config.workerPath is null if you never set it [5]. Resource timing does not lie about fetches either: filter performance.getEntriesByType('resource') for names containing "worker", and an empty array means the worker file was never requested [6]. Chrome's DevTools thread list, or Firefox's worker list in the debugger, answers from the other side, where only the main thread present means transmuxing is inline [7].

The fix supplies a real URL to workerPath. The pattern the post gives is to obtain a real URL for hls.js/dist/hls.worker.js and hand it to workerPath, with the syntax differing per bundler [20]: Vite and Rollup take an import carrying the ?url suffix [8], webpack 5 builds one from new URL against import.meta.url [9]. That is also why the post insists on a runtime check inside MANIFEST_PARSED, since a wrong path fails silently [10].

The instrumentation half is worth having even if you set workerPath years ago. A buffer stall tells you playback ran dry; it does not tell you whether the bytes were late or the thread was busy [12]. A longtask PerformanceObserver reports every task that held the main thread past 50ms [11], so summing durations over a trailing window gives you a blocked-ms number to attach to each stall event. The post's two illustrative analytics rows are blocked_ms_3s of 1180 with 0.2s of buffer left, and blocked_ms_3s of 0 with 0.1s of buffer left, both on level 3 [13][14].

1180 ms inside a 3000 ms window is about 39 percent of that window with the main thread unavailable [16]. The aggregate hides shape: because a long task is anything over 50 ms, 1180 ms is somewhere between one 1.2-second task and 23 barely-long ones [17], and those two want different fixes. Treat the pair of rows as a picture of the field names, not as an expected value. The piece publishes no before-and-after timing, so the magnitude only transfers to a device class and segment size that generate transmux work of that order.

The prevalence claim deserves the same discount. The headline asserts most ESM setups are not using a worker [18], and nothing in the piece measures that. The mechanism is well evidenced: the ESM build has shipped the worker as a separate file since 1.4 [1], nothing loads it until workerPath is set [2], and the author says everything was checked against 1.7.x [4]. "Most" remains an assertion about other people's builds, and the console paste settles your own case in a second.

One thread the piece raises but never resolves: Chromium has supported MediaSource inside a dedicated worker since Chrome 108, flagged there as a different matter [15], and the published text breaks off before saying what hls.js does with it. Nothing here supports a claim about moving buffer handling off the main thread. Until your stall events carry a blocked-ms field, a busy thread and a slow CDN land in your dashboard as the same row.

What to watch

  • Whether hls.js resolves the worker URL itself in the ESM build, which would retire the per-bundler workerPath boilerplate.
  • Whether anyone publishes a measurement that replaces the "most ESM setups" headline claim with a counted number.
  • Whether MediaSource-in-worker support, available in Chromium since Chrome 108, gets wired into hls.js in a way that moves more than transmuxing off the main thread.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories