How we embedded a Web Worker in an npm package

· 4 min read

Every library that runs work off the main thread has the same install problem. The worker is a separate script, browsers load it from a URL, and the URL depends on the bundler your user happens to be using. So the README grows a section: Vite users do this. Next.js users do that. Copy this file into public/.

We had that section. It was the single most common thing people got wrong.

In @kanunilabs/[email protected] it is gone. There is no file to copy, no bundler recipe, and no workerUrl to set:

import { BasePivotGrid } from '@kanunilabs/pivotgrid-react';

<BasePivotGrid data={rows} initialFields={fields} />

This is how, what it cost, and where it can bite you.

The idea

A Worker can be constructed 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 code is a string inside your bundle, there is nothing left to resolve at runtime. No URL, no bundler involvement, no copying.

The catch is the word source. That string has to be a complete, standalone program. A worker that still contains import statements will construct fine and then fail the moment it runs, because a Blob URL has no module graph to resolve those imports against.

The build

Three steps, in this order.

1. Compile the workers on their own. A second bundler config builds each worker as a self-contained file — no code splitting, and every workspace dependency inlined rather than left as an import:

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

2. Turn each built file into a TypeScript module. A small script reads the compiled output and writes it back out as a string constant:

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

JSON.stringify is doing real work here: it escapes quotes, backslashes and newlines so the result is a valid literal no matter what the minifier emitted.

3. Build the package normally. The main bundle now imports that generated module like any other file, and the worker source rides along inside it.

Two ways this quietly breaks

Both of these produce a package that installs cleanly, type-checks, and fails only on a customer's machine. They are worth guarding explicitly.

The main build deletes the workers. Most bundler configs default to cleaning the output directory. Run the worker build first and the main build second with cleaning left on, and step three erases what step one produced. The package still publishes — it just has no worker files for the fallback path. Only one config may own clean.

The embedded source is not standalone. If a refactor turns an inlined dependency back into an external import, the Blob worker starts throwing at runtime while every local test keeps passing. So the generation script refuses to write a file that fails three checks: a minimum byte size, no import or export statements, and the presence of an onmessage handler.

The size floor is per worker, not global. Ours are 16 KB, 10 KB and 3 KB. A single threshold either lets a broken 16 KB worker through or rejects a perfectly good 3 KB one.

What it costs

Embedding three workers grew the core bundle by 30.1 KB — 17.4% (173.2 KB → 203.3 KB, minified, before gzip).

That is not free, and we would not pretend otherwise. It buys the deletion of an entire setup step, in a library whose whole pitch is that it stays out of your way. For a single worker the figure was much smaller: DataGrid paid 6.1% for the same change.

Keep an escape hatch

One environment legitimately cannot run a Blob worker: a Content-Security-Policy that does not allow blob: in worker-src. Do not make that a dead end.

export function startEmbeddedWorker(kind, resolveUrl) {
  try {
    return new Worker(embeddedWorkerUrl(kind), { type: 'module' });
  } catch {
    // CSP blocked the Blob. Fall back to the pre-embedding behaviour rather
    // than losing the worker entirely.
    return new Worker(resolveUrl(), { type: 'module' });
  }
}

The worker files still ship in the package, so the fallback has something to load. And an explicitly supplied workerUrl still wins over both — that path exists for people serving the worker from their own CDN, and embedding should not take it away from them.

Verifying it actually works

The failure mode here is silent, so "it looks fine" is not evidence. Two checks are worth automating:

  1. Intercept the Worker constructor on a real page and assert the URL it receives starts with blob:. Ours does.
  2. Grep the published tarball. Take a long, quote-free fragment from each compiled worker and search for it inside dist/index.js. If the source is really embedded, it is there verbatim.

We ran the second check against a clean-room npm install from the public registry — not the local build — and found all nine fragments from all three workers. That is the only version of this claim worth making.


This is one of the changes in PivotGrid 1.0.5. The setup it removes is described in Installation.