<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Tom Limb</title>
    <description>The latest articles on DEV Community by Tom Limb (@tom_limb).</description>
    <link>https://dev.to/tom_limb</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4130312%2F6b7b3fc5-3e59-4bf2-9586-5306552c442d.png</url>
      <title>DEV Community: Tom Limb</title>
      <link>https://dev.to/tom_limb</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tom_limb"/>
    <language>en</language>
    <item>
      <title>Safari runs a module worker's entry file twice if anything imports it</title>
      <dc:creator>Tom Limb</dc:creator>
      <pubDate>Thu, 17 Sep 2026 18:10:14 +0000</pubDate>
      <link>https://dev.to/tom_limb/safari-runs-a-module-workers-entry-file-twice-if-anything-imports-it-dbp</link>
      <guid>https://dev.to/tom_limb/safari-runs-a-module-workers-entry-file-twice-if-anything-imports-it-dbp</guid>
      <description>&lt;p&gt;We're building &lt;a href="https://ohsovideo.com" rel="noopener noreferrer"&gt;Oh So Video&lt;/a&gt;, 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.&lt;/p&gt;

&lt;p&gt;Everything passed except one thing. In Safari, every ProRes file was refused with this message:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This browser couldn't read this ProRes video. Try Chrome, Edge or Safari on a computer.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That was Safari, on a computer. Chrome and Firefox converted the same files fine, including a 5 GB 4K one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually happening
&lt;/h2&gt;

&lt;p&gt;We decode ProRes with &lt;a href="https://mediabunny.dev" rel="noopener noreferrer"&gt;Mediabunny&lt;/a&gt;'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.&lt;/p&gt;

&lt;p&gt;In Safari, the registry was empty. The decoder had registered with a &lt;em&gt;different copy of Mediabunny&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;X&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;L&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;J&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;C&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;L&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;V&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./encode.worker-BrkeQj5R.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The smallest repro
&lt;/h2&gt;

&lt;p&gt;Two files. No bundler, no dynamic import, just an ordinary import cycle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// w.js ‚Äî started with new Worker("w.js", { type: "module" })&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;helperSeesId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./helper.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;globalThis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;evals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;globalThis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;evals&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;evals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;globalThis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;evals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;same&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;helperSeesId&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// helper.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./w.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;helperSeesId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Chrome and Firefox reply &lt;code&gt;{ evals: 1, same: true }&lt;/code&gt;. Safari replies &lt;code&gt;{ evals: 2, same: false }&lt;/code&gt;: the entry ran twice, and &lt;code&gt;helper.js&lt;/code&gt; holds the second copy.&lt;/p&gt;

&lt;p&gt;To try it, &lt;a href="https://bugs.webkit.org/attachment.cgi?id=481409" rel="noopener noreferrer"&gt;download the three files&lt;/a&gt; (&lt;code&gt;index.html&lt;/code&gt;, &lt;code&gt;w.js&lt;/code&gt;, &lt;code&gt;helper.js&lt;/code&gt;) from the WebKit bug and serve them from any local web server, since module workers don't load from &lt;code&gt;file://&lt;/code&gt;. For example, run &lt;code&gt;python3 -m http.server&lt;/code&gt; in the folder and open &lt;code&gt;http://localhost:8000&lt;/code&gt; in Safari.&lt;/p&gt;

&lt;p&gt;We tried several shapes of the same idea, in Safari 26.5 (macOS 26.5.1), Firefox 156 and Chrome 153:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Case&lt;/th&gt;
&lt;th&gt;Safari 26.5&lt;/th&gt;
&lt;th&gt;Firefox 156&lt;/th&gt;
&lt;th&gt;Chrome 153&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A worker's entry imports itself with &lt;code&gt;import("./w.js")&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Runs twice&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Once&lt;/td&gt;
&lt;td&gt;Once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A lazy chunk imports from the worker's entry (the bundler shape)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Runs twice&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Once&lt;/td&gt;
&lt;td&gt;Once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A plain static cycle: entry ‚Üí helper ‚Üí entry&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Runs twice&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Once&lt;/td&gt;
&lt;td&gt;Once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The same, importing the entry by absolute URL&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Runs twice&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Once&lt;/td&gt;
&lt;td&gt;Once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The bundler shape, but on the page instead of in a worker&lt;/td&gt;
&lt;td&gt;Once&lt;/td&gt;
&lt;td&gt;Once&lt;/td&gt;
&lt;td&gt;Once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Entry and chunk both import a separate shared module&lt;/td&gt;
&lt;td&gt;Once&lt;/td&gt;
&lt;td&gt;Once&lt;/td&gt;
&lt;td&gt;Once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A thin entry that only does &lt;code&gt;import("./main.js")&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Once&lt;/td&gt;
&lt;td&gt;Once&lt;/td&gt;
&lt;td&gt;Once&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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 &lt;a href="https://github.com/brandon-fryslie/slopspot-paste/pull/134" rel="noopener noreferrer"&gt;ran into the same thing in Safari 26.3&lt;/a&gt;, so it isn't new.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's a bug
&lt;/h2&gt;

&lt;p&gt;The HTML Standard gives each worker &lt;a href="https://html.spec.whatwg.org/multipage/workers.html#run-a-worker" rel="noopener noreferrer"&gt;a module map&lt;/a&gt;, and fetches a module worker's top-level script through it, keyed by URL. The &lt;a href="https://html.spec.whatwg.org/multipage/webappapis.html#module-map" rel="noopener noreferrer"&gt;module map&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;(One caveat: the key is the exact URL. A worker started as &lt;code&gt;w.js?v=2&lt;/code&gt; and imported later as &lt;code&gt;w.js&lt;/code&gt; is legitimately two modules.)&lt;/p&gt;

&lt;p&gt;We couldn't find a WebKit bug for it, or a web-platform test that covers it. We've filed it as &lt;a href="https://bugs.webkit.org/show_bug.cgi?id=324459" rel="noopener noreferrer"&gt;WebKit bug 324459&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you might have this and not know
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Duplicated state:&lt;/strong&gt; registries, caches, singletons, "already initialised" flags, WASM instances.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Top-level code running twice:&lt;/strong&gt; listeners added twice, &lt;code&gt;onmessage&lt;/code&gt; reassigned, requests sent twice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing throws. Something just quietly doesn't work, and only in Safari.&lt;/p&gt;

&lt;p&gt;To check a build, look in your worker's lazily loaded chunks for an import of the worker entry's own file name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="s1"&gt;'from"./your.worker-'&lt;/span&gt; dist/assets/&lt;span class="k"&gt;*&lt;/span&gt;.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any file that isn't the entry itself is affected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two fixes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Move the shared code out of the entry.&lt;/strong&gt; This is what we did. With Rollup's &lt;code&gt;manualChunks&lt;/code&gt;, Mediabunny gets a chunk of its own, and the entry and the ProRes chunk both import that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vite.config.js (in Astro: vite: { ... } in astro.config.mjs)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;es&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;rollupOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;manualChunks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
          &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/node_modules/mediabunny/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mediabunny&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Safari now converts both our test files, 623 MB and 5 GB of ProRes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Make the entry a thin loader.&lt;/strong&gt; If the worker's entry only does &lt;code&gt;import("./main.js")&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;The first fix is narrower: it moves one library. The second covers anything a bundler might hoist into the entry later.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we found it
&lt;/h2&gt;

&lt;p&gt;This only surfaced because we ran every tool, end to end, in the real browsers before sending people to them: WebDriver through &lt;code&gt;safaridriver&lt;/code&gt; and &lt;code&gt;geckodriver&lt;/code&gt;, 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.&lt;/p&gt;

</description>
      <category>safari</category>
      <category>webkit</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
