deniz.in

Markets

Weather

Loading weather

· via dev.to (home feed)

Most ESM setups of hls.js silently skip the worker and transmux on the main thread

A dev.to post explains that importing hls.js via ESM leaves the transmuxer worker unloaded unless workerPath is set, and shows how to verify the fix and tell main-thread stalls from network stalls.

Most ESM setups of hls.js silently skip the worker and transmux on the main thread

The ESM build ships its worker separately

A post on dev.to walks through a quiet performance trap in hls.js, one of the most widely used HLS playback libraries on the web. According to the post, the library's ESM build — introduced in hls.js 1.4 as dist/hls.mjs — ships its transmuxer worker as a separate file instead of inlining it the way older builds did. A plain import Hls from 'hls.js' in a bundler-based project therefore usually resolves to the ESM build without a worker, and every demux and remux operation runs on the main thread until you explicitly point the library at the worker file. The author notes the findings were checked against hls.js 1.7.x.

The configuration defaults make this easy to miss. enableWorker defaults to true and stays true even when no worker can be created, meaning "use a worker if one is available" rather than "a worker is running". The post calls this the single biggest source of false confidence around the issue.

How to check what you are actually running

Rather than assuming, the post recommends inspecting the browser during playback. Filtering performance.getEntriesByType('resource') for entries containing "worker" should surface the worker script; an empty result means no worker file was ever fetched. DevTools users can cross-check the Threads view in Chrome's Sources panel or the worker list in Firefox's debugger — if only the main thread appears, transmuxing is happening inline. The library can also be asked directly: hls.config.workerPath is null when it was never set.

The fix is a single config option

The remedy is to resolve a real URL for hls.js/dist/hls.worker.js and pass it as workerPath, with syntax varying by bundler. In Vite or Rollup, the file can be imported with the ?url suffix to get an asset URL. In webpack 5 and Next.js client components, new URL('hls.js/dist/hls.worker.js', import.meta.url).toString() produces the equivalent. Because an incorrect path fails silently, the author suggests verifying after the MANIFEST_PARSED event that a resource entry containing "hls.worker" actually appears in the resource timing buffer.

Distinguishing main-thread stalls from network stalls

A buffer stall event only tells you that playback ran dry, not whether the bytes arrived late or the thread was busy. The post pairs stall events with a PerformanceObserver watching for longtask entries, which report tasks holding the main thread for more than 50ms. By keeping a rolling window of recent long tasks and summing their durations, each stall can be tagged with a blocked-milliseconds figure alongside the buffer length. A stall with over a second of blocked time and an empty buffer points to a starved main thread; a stall with zero blocked time points to the network. Those are, as the post puts it, two completely different tickets that most teams currently file in the same bucket.

Two caveats are worth noting. Safari does not support the longtask entry type, so the post recommends feature-detecting via PerformanceObserver.supportedEntryTypes and falling back to counting requestAnimationFrame gaps. The author also suggests validating the instrumentation by deliberately blocking the main thread for 120ms every 500ms and running the player with and without workerPath, and throttling the CPU rather than the network in DevTools — most player testing throttles only the network, which is why this class of bug reaches production.

What the worker does not fix

Even with the worker configured, its scope is limited. According to the post, segment fetching happens on the network thread and demuxing and remuxing move to the worker, but the MediaSource and its SourceBuffer.appendBuffer() calls still live on the main thread, where they contend with application code. Decode and render remain in the browser's media pipeline.

The platform-level answer is constructing the MediaSource inside a dedicated worker, which Chromium enabled by default in Chrome 108 (with Opera 94 following). Firefox and Safari have not shipped it, and the post notes that no mainstream HLS library drives it end to end today, so it should be treated as a Chromium-only enhancement rather than an architectural foundation. The post also mentions preferManagedMediaSource, added in hls.js 1.5.0, which controls whether the library picks ManagedMediaSource over classic MediaSource where both exist; setting it to false forces the traditional path.

Why it matters

Video playback is one of the heaviest recurring workloads a web page can run, and this is a case where the library's intended optimization is silently absent in the most common modern setup. Moving transmuxing back off the main thread is a one-line configuration change, but almost nobody knows to make it because the config flag suggests it is already on. Beyond the immediate fix, the post's instrumentation approach has broader value: telemetry that separates main-thread stalls from network stalls turns vague buffering complaints into diagnosable, correctly assigned incidents.

  • #hls-js
  • #web-workers
  • #javascript
  • #streaming
  • #video

Related posts