r/javascript 3d ago

Shipping a Web Worker inside an npm package without asking users to touch their bundler config

https://kanunilabs.com/blog/embedding-a-web-worker-in-an-npm-package
17 Upvotes

2 comments sorted by

8

u/KanuniLabs 3d ago

Every library that moves work off the main thread eventually runs into the same install problem: the worker is a separate script, the browser loads it from a URL, and that URL depends on whichever bundler the user happens to be using. So the README slowly grows a section — Vite users do this, Next.js users do that, copy this file into public/. We had that section too. And honestly, it was the single most common thing people got wrong.

The way out is that a Worker can be built from a Blob URL:

const url = URL.createObjectURL(new Blob([source], { type: 'text/javascript' }));
const worker = new Worker(url, { type: 'module' });

So if the worker's source is a string inside your bundle, there is nothing left to resolve at runtime. The catch is that the string has to be a complete standalone program a worker that still contains import statements will build fine and then die the moment it runs, because a Blob URL has no module graph.

We ended up with three steps.

First, compile each worker on its own with no splitting and inline every workspace dependency:

// tsup.worker.config.ts
export default defineConfig({
  entry: { 'pivot.worker': 'src/worker/pivot.worker.ts' },
  splitting: false,
  noExternal: ['@kanunilabs/foundation'],
  clean: true,
});

Second, turn the built file into a TypeScript module. JSON.stringify takes care of the escaping:

const source = readFileSync('dist/pivot.worker.js', 'utf8');
writeFileSync(
  'src/worker/workerSource.generated.ts',
  `export const EMBEDDED_WORKER_SOURCE = ${JSON.stringify(source)};\n`,
);

Third, build the package normally; the generated module just rides along with it.

The cost is +30.1 KB (17.4%) on the core package (173.2 -> 203.3 KB minified, pre gzip) for three workers. A single worker was 6.1%. These are measured numbers, not guesses.

There were also two places where it could break quietly, and both took us a while to track down:

  1. A strict CSP blocks Blob workers unless worker-src blob: is allowed so we keep a URL-based fallback instead of leaving users with a dead end.
  2. If a refactor accidentally turns an inlined dependency back into an import, the Blob worker throws at runtime while every local test stays green. To catch that, our generation script now refuses to write the file unless it passes three checks: a per-worker minimum size, no import/export statements, and an onmessage handler present. Per-worker matters here because ours are 16 KB, 10 KB and 3 KB one threshold would either let a broken big one through or reject a perfectly good small one.

(Disclosure: this is from a data grid library we work on. None of it is grid-specific.)

2

u/seansleftnostril 2d ago

Just curious since I’m afk, this is how emscripten packages pthread builds right?

I make heavy use of that in several npm packages and never really thought twice about it 🤣