Compiling Wasm in a Worker to Free the Main Thread
This guide moves the compilation of a large WebAssembly module into a Web Worker and transfers the
compiled Module back to the page, so the main thread stays responsive to input while the work
happens.
The reason to bother is that streaming compilation overlaps download and compile, but it does not make the compile disappear — the work runs somewhere, and for a multi-megabyte binary “somewhere” can mean a long task on the thread that also handles clicks and rendering. Moving it to a worker removes it from that thread entirely.
Prerequisites
- [ ] A module large enough that compilation shows as a long task — check before adding complexity
- [ ] A build setup that can emit a worker entry point with a stable URL
- [ ]
WebAssembly.Modulestructured-clone support, which every current browser has - [ ] The Performance panel, to confirm the long task actually moved
Step-by-step procedure
1. Write the worker
// compile-worker.js
self.onmessage = async ({ data: { url } }) => {
try {
const module = await WebAssembly.compileStreaming(fetch(url));
self.postMessage({ ok: true, module }); // structured clone, no transfer list needed
} catch (error) {
self.postMessage({ ok: false, error: String(error) });
}
};
compileStreaming produces a Module without instantiating it, which is exactly what can cross a
worker boundary. An Instance cannot — it holds live memory bound to its own realm.
2. Start it early
const worker = new Worker(new URL("./compile-worker.js", import.meta.url), { type: "module" });
const modulePromise = new Promise((resolve, reject) => {
worker.onmessage = ({ data }) =>
data.ok ? resolve(data.module) : reject(new Error(data.error));
});
worker.postMessage({ url: new URL("./wasm/kernel.wasm", import.meta.url).href });
Kick the worker off as early as you can — during application bootstrap, not when the feature is first used — so the compile overlaps with everything else the page is doing.
3. Instantiate on the main thread when you need it
const module = await modulePromise;
const instance = await WebAssembly.instantiate(module, imports);
Instantiation is the cheap phase and must happen where the imports live, so it stays on the main thread. That is a millisecond or two rather than the hundreds compilation could have cost.
4. Or keep the instance in the worker entirely
If the module’s work is itself long-running, do not transfer anything — instantiate in the worker and expose a message-based API:
// inside the worker
const instance = await WebAssembly.instantiate(module, {});
self.onmessage = ({ data }) => {
const result = instance.exports.process(data.ptr, data.len);
self.postMessage({ result });
};
5. Confirm the long task moved
Record a Performance profile and look at the main thread during load. The compile block should be absent from it and present on the worker’s track.
Expected output
Before and after, from the Performance panel on a 2 MB module:
before
Main thread ▓▓▓ Compile Wasm 412 ms ← long task, input delayed
Worker (none)
after
Main thread ▓ Instantiate 2 ms
Worker ▓▓▓ Compile Wasm 418 ms
The total work is the same — slightly more, in fact, because of the message overhead. What changed is which thread paid for it, and therefore whether the page responded to input during those four hundred milliseconds.
Gotchas
The worker cannot fetch the binary. Its base URL differs from the page’s, so a relative path resolves against the worker script. Pass an absolute URL in the message, as above.
Posting the module fails. Some engines restrict structured-cloning modules across certain
boundaries — notably to and from a SharedWorker, or across origins. Fall back to compiling on the
main thread rather than treating it as fatal.
The worker is created too late. Spawning it at the moment the module is needed adds worker startup to the critical path. Create it during bootstrap.
Two copies end up compiled. The main thread also started a compile as a fallback, and both finished. Make the worker path the only path, with a fallback that runs only after failure.
Nothing improved. The compile was never the long task. Profile before restructuring — see measuring Wasm compile time in DevTools.
Performance note
Worker creation costs a few milliseconds, and the message carrying a compiled module costs a little more than an empty one. Against a compile measured in hundreds of milliseconds that overhead is irrelevant; against a compile of ten milliseconds it is the dominant cost, and the whole exercise is a net loss. The rough threshold is around fifty milliseconds of compile time — below that, do not bother.
There is a second-order benefit worth noting: a worker that already exists for compilation is well-placed to keep the instance and do the module’s actual work, which removes the boundary crossings from the main thread as well. If the module’s job is long-running computation rather than a quick transformation, going straight to that architecture is usually better than transferring the module back.
Combining it with the module cache
The two techniques compose well, and the worker is the natural place to own both. Have it check IndexedDB first, compile only on a miss, store the result, and post the module back either way:
// compile-worker.js
self.onmessage = async ({ data: { url, key } }) => {
const db = await openDb().catch(() => null);
let module = db ? await getModule(db, key).catch(() => null) : null;
const hit = module instanceof WebAssembly.Module;
if (!hit) {
module = await WebAssembly.compileStreaming(fetch(url));
if (db) putModule(db, key, module).catch(() => {});
}
self.postMessage({ module, hit });
};
That arrangement puts every expensive operation — the fetch, the compile, and the storage write — on the worker’s thread, and leaves the main thread with a single structured-clone receive and a two millisecond instantiation. On a repeat visit the worker returns almost immediately; on a first visit it takes as long as compilation does, without blocking anything the user can see.
The one subtlety is that the worker and the page have separate IndexedDB connections but the same underlying database, so a write from the worker is visible to a later read from the page. Keeping all access in the worker avoids reasoning about that at all.
Deciding whether it is worth the plumbing
The honest threshold is around fifty milliseconds of compile time. Below that, worker creation and the message round trip are a comparable cost and the added indirection buys nothing measurable. Above it, the benefit grows linearly with module size and is entirely one-sided: the main thread simply stops doing the work.
Two signals say you are above the threshold. The Performance panel flags the compile as a long task, which means it exceeded fifty milliseconds and delayed input handling. And the interaction-to-next-paint measurement worsens during load, which is the user-visible form of the same thing.
If neither is true, the simpler options are better: shrink the binary, cache the compiled module, or defer the load. A worker is worth reaching for when the compile is genuinely large and genuinely on the critical path — and in that case it is the only technique that removes the cost from the main thread rather than merely reducing it.
Frequently Asked Questions
Can I transfer an Instance instead?
No. An Instance is bound to its memory and its realm. Transfer the Module and instantiate on the
receiving side, or keep both in the worker.
Does this work with wasm-bindgen glue?
Yes, but the glue expects to own instantiation. The simplest arrangement is to run the glue entirely
inside the worker and expose your own message API, rather than trying to hand a module to glue running
on the page.
Should every module be compiled in a worker? No — only the ones large enough to produce a long task. For small modules the added machinery is a maintenance cost with no user-visible benefit.
Can several workers share one compiled module?
Yes — post the same Module to each, and each instantiates its own instance from it. That saves
compiling once per worker, which for a pool of four is a substantial saving on startup, and it is one
of the clearer arguments for compiling in a dedicated worker rather than in each pool member.
Is there a downside to always compiling in a worker? Two small ones. The worker itself costs a few milliseconds to start and a little memory, which is irrelevant for a large module and proportionally significant for a tiny one. And the added indirection makes the load path harder to follow when something goes wrong, since a failure inside the worker surfaces as a message rather than as an exception at the call site. Both are reasons to apply it where the compile is genuinely large rather than as a blanket policy.
Related
- Module caching & startup performance — the other levers on the same problem.
- Caching compiled Wasm modules in IndexedDB — skipping the compile altogether next time.
- Sharing memory between Wasm and Web Workers — when the worker keeps the instance and the data.
- Debugging and profiling Wasm modules — reading the profile that justifies this change.
← Back to Module Caching & Startup Performance