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:
- Intercept the
Workerconstructor on a real page and assert the URL it receives starts withblob:. Ours does. - 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.