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.

Where the compile work runs Compilation on the main thread appears as a long task that delays input handling and rendering. Compiling in a worker leaves the main thread free; the compiled module is transferred back when it is ready, and only the short instantiation happens on the page. compiling on the main thread render compile — a long task, input queued behind it render click arrives here, handled much later compiling in a worker main inst. responsive throughout worker fetch + compile The Module is transferred, not copied — a structured clone that hands over the compiled artifact rather than the bytes.

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.Module structured-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.

Same total, different thread Moving compilation to a worker does not reduce the total CPU time spent; it slightly increases it because of message overhead. What it removes is the main thread's share, which is the part users experience as unresponsiveness. main thread, before 412 ms blocked main thread, after 2 ms — instantiation only worker, after 418 ms This is a responsiveness fix, not a speed fix. If the module is not on the critical path at all, deferring it is simpler and better. Combine with a compiled-module cache and the second visit skips the worker entirely.

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.

What can and cannot be transferred A compiled Module is stateless and structured-cloneable, so it can be posted between realms. An Instance owns live memory bound to its realm and cannot cross, which is why instantiation always happens on the receiving side. WebAssembly.Module stateless, shareable, cloneable crosses the boundary freely WebAssembly.Instance owns memory bound to its realm cannot be posted anywhere So the split is always the same: compile wherever is convenient, instantiate wherever the imports and the work live.

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.

← Back to Module Caching & Startup Performance