Safari runs a module worker's entry file twice if anything imports it
DEV Community

Safari runs a module worker's entry file twice if anything imports it

The Problem

We're building Oh So Video, a set of video tools that run entirely in the browser: nothing is uploaded, and the work happens in Web Workers on your own machine. Before launch, we automated a full run of every tool in Safari and Firefox, checking every exported file with ffmpeg. Everything passed except one thing. In Safari, every ProRes file was refused with this message: This browser couldn't read this ProRes video. Try Chrome, Edge or Safari on a computer. That was Safari, on a computer. Chrome and Firefox converted the same files fine, including a 5 GB 4K one.

What Was Actually Happening

We decode ProRes with Mediabunny's ProRes extension, which only loads when someone chooses a ProRes file. When it loads, it registers a decoder with Mediabunny, and Mediabunny checks that registry before saying whether it can read a video. In Safari, the registry was empty. The decoder had registered with a different copy of Mediabunny. Our build (Vite 6.4 and Rollup 4) had put Mediabunny inside the encode worker's entry file. The lazily loaded ProRes chunk then imported Mediabunny from there. Its first line was:

import { r as X , L as J , C as L , V as k } from "./encode.worker-BrkeQj5R.js";

In Chrome and Firefox, that import returns the worker's already-running entry module. In Safari, it loads and runs the entry file again, as a new module with its own state. The ProRes decoder registered with that copy, and the code checking the registry never saw it.

The Smallest Reproduction

Two files. No bundler, no dynamic import, just an ordinary import cycle:

// w.js - started with new Worker("w.js", { type: "module" })
import { helperSeesId } from "./helper.js";
export const id = Math.random();
globalThis.evals = (globalThis.evals ?? 0) + 1;
self.onmessage = () => self.postMessage({ evals: globalThis.evals, same: helperSeesId() === id });
// helper.js
import * as entry from "./w.js";
export const helperSeesId = () => entry.id;

Chrome and Firefox reply { evals: 1, same: true }. Safari replies { evals: 2, same: false }: the entry ran twice, and helper.js holds the second copy.

To try it, download the three files (index.html, w.js, helper.js) from the WebKit bug and serve them from any local web server, since module workers don't load from file://. For example, run python3 -m http.server in the folder and open http://localhost:8000 in Safari.

Test Results Across Browsers

We tried several shapes of the same idea, in Safari 26.5 (macOS 26.5.1), Firefox 156 and Chrome 153:

Case Safari 26.5 Firefox 156 Chrome 153
A worker's entry imports itself with import("./w.js") Runs twice Once Once
A lazy chunk imports from the worker's entry (the bundler shape) Runs twice Once Once
A plain static cycle: entry → helper → entry Runs twice Once Once
The same, importing the entry by absolute URL Runs twice Once Once
The bundler shape, but on the page instead of in a worker Once Once Once
Entry and chunk both import a separate shared module Once Once Once
A thin entry that only does import("./main.js") Once Once Once

It only happens in workers, and only when something imports the worker's own entry file. Safari behaves as if the entry script isn't in the worker's module map, so importing its URL loads it fresh. We confirmed the results in normal Safari as well as under WebDriver. Another developer ran into the same thing in Safari 26.3, so it isn't new.

Why It's a Bug

The HTML Standard gives each worker a module map, and fetches a module worker's top-level script through it, keyed by URL. The module map exists "to ensure that imported module scripts are only fetched, parsed, and evaluated once per Document or worker." A later import of the same URL should get the same module back, which is what Chrome and Firefox do. (One caveat: the key is the exact URL. A worker started as w.js?v=2 and imported later as w.js is legitimately two modules.)

We couldn't find a WebKit bug for it, or a web-platform test that covers it. We've filed it as WebKit bug 324459.

Why You Might Have This and Not Know

Any worker built with code splitting can end up in this shape. Bundlers often move code shared between a worker's entry and its lazily loaded chunks into the entry chunk, and the chunks then import it back from there. In Safari you then get:

  • Duplicated state: registries, caches, singletons, "already initialised" flags, WASM instances.
  • Top-level code running twice: listeners added twice, onmessage reassigned, requests sent twice.

Nothing throws. Something just quietly doesn't work, and only in Safari. To check a build, look in your worker's lazily loaded chunks for an import of the worker entry's own file name:

grep -l 'from"./your.worker-' dist/assets/*.js

Any file that isn't the entry itself is affected.

Two Fixes

  1. Move the shared code out of the entry. This is what we did. With Rollup's manualChunks, Mediabunny gets a chunk of its own, and the entry and the ProRes chunk both import that:
// vite.config.js (in Astro: vite: { ... } in astro.config.mjs)
export default {
  worker: {
    format: "es",
    rollupOptions: {
      output: {
        manualChunks: (id) => id.includes("/node_modules/mediabunny/") ? "mediabunny" : undefined,
      },
    },
  },
};

Safari now converts both our test files, 623 MB and 5 GB of ProRes.

  1. Make the entry a thin loader. If the worker's entry only does import("./main.js"), nothing ever imports the entry, so there's nothing to duplicate. This is the fix the other developer used, and it passes in all three browsers too. The first fix is narrower: it moves one library. The second covers anything a bundler might hoist into the entry later.

How We Found It

This only surfaced because we ran every tool, end to end, in the real browsers before sending people to them: WebDriver through safaridriver and geckodriver, real video files, and every exported file checked with ffprobe and decoded with ffmpeg. The failure produced a polite, plausible error message, which is exactly the kind of bug that sails through a quick manual check.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.