Loading Wasm in a Web Worker with ESM
This guide runs a WebAssembly module inside a Web Worker using ES module syntax on both sides — creating the worker so a bundler can see it, getting the binary’s URL across correctly, and returning results without copying more than necessary.
The reason to do this is that a worker gives the module its own thread. Compilation, instantiation and every subsequent call happen off the main thread, so a long computation cannot delay input handling or rendering. The complications are all in the plumbing: workers have their own base URL, their own module graph, and no access to anything the page holds.
Prerequisites
- [ ] A bundler that understands
new Worker(new URL("./w.js", import.meta.url), { type: "module" }) - [ ] A
.wasmserved withapplication/wasm - [ ] Module worker support — every current browser, but check if you support older ones
- [ ] A task boundary worth the message round trip; per-pixel messaging defeats the purpose
Step-by-step procedure
1. Create the worker so the bundler can see it
// main.js
const worker = new Worker(new URL("./compute.worker.js", import.meta.url), { type: "module" });
The new URL(..., import.meta.url) form is the one bundlers recognise. A bare string path is not
rewritten, so the built output points at a file that does not exist — the most common failure in this
whole workflow.
2. Send the binary URL rather than importing it inside the worker
// main.js
import wasmUrl from "./wasm/kernel.wasm?url";
worker.postMessage({ type: "init", wasmUrl });
The worker runs outside the page’s module graph, so a relative import inside it resolves against the worker script rather than the page. Passing the hashed URL keeps the reference correct in development and in production.
3. Instantiate inside the worker
// compute.worker.js
let instance;
self.onmessage = async ({ data }) => {
if (data.type === "init") {
const result = await WebAssembly.instantiateStreaming(fetch(data.wasmUrl), {});
instance = result.instance;
self.postMessage({ type: "ready" });
return;
}
if (data.type === "task") {
const out = run(data.input);
self.postMessage({ type: "result", out }, [out.buffer]); // transfer, do not copy
}
};
4. Transfer buffers rather than copying them
function run(input) {
const ptr = instance.exports.alloc(input.length);
new Uint8Array(instance.exports.memory.buffer, ptr, input.length).set(input);
instance.exports.process(ptr, input.length);
const out = new Uint8Array(input.length);
out.set(new Uint8Array(instance.exports.memory.buffer, ptr, input.length));
instance.exports.free(ptr, input.length);
return out;
}
The copy out of linear memory is unavoidable — a view over the module’s buffer cannot be transferred,
because the module still owns it. Copying into a fresh Uint8Array and transferring that is the
correct pattern, and it moves ownership without a second copy.
5. Wrap the message protocol in a promise API
// main.js
const pending = new Map();
let nextId = 0;
worker.onmessage = ({ data }) => {
const resolve = pending.get(data.id);
if (resolve) { pending.delete(data.id); resolve(data.out); }
};
export function process(input) {
const id = nextId++;
return new Promise((resolve) => {
pending.set(id, resolve);
worker.postMessage({ type: "task", id, input }, [input.buffer]);
});
}
Callers then see an ordinary async function and never deal with messages at all.
Expected output
$ npm run build && npm run preview
# Network panel
compute.worker-8a2f1c.js 3.1 kB script
kernel-4b7e0d.wasm 74.9 kB fetch (requested by the worker)
# console
worker ready in 91 ms
process(1 MB) → 12.4 ms, main thread idle throughout
Two details in that output confirm the setup is right. The worker script is hashed, which means the
bundler processed it rather than copying a stray file. And the .wasm request is attributed to the
worker rather than the page, which means the URL survived the message.
Gotchas
The worker 404s in the built output. The new URL(..., import.meta.url) form was not used, so the
bundler never processed the worker file. This works in development and fails after a build, which is
the worst combination.
The .wasm 404s from inside the worker. A relative path resolved against the worker’s own URL.
Pass the hashed URL in the init message.
import fails inside the worker. The worker was created without { type: "module" }. Classic
workers cannot use ES module syntax.
Transferring a view over linear memory throws. Those buffers belong to the module and are not
transferable. Copy into a fresh array first.
The worker starts fetching before the page is ready. Worker creation is asynchronous but not
ordered relative to your init message — send the URL and wait for the ready reply before posting
tasks.
Performance note
The message round trip costs a fraction of a millisecond plus whatever the payload costs to transfer.
Transferring an ArrayBuffer is O(1) — ownership moves, no bytes are copied — while a structured clone
of the same data is proportional to its size, so the transfer list is not an optimisation detail but
the difference between constant and linear cost per task.
Worker startup is the other fixed cost: a few milliseconds for the worker plus the module’s fetch and compile. That makes a per-task worker a poor design and a long-lived one a good one. Create it during application bootstrap, keep the instance resident, and send it work — the same reasoning that applies to worker pools over shared memory, without the cross-origin isolation requirement.
Growing to a pool
One worker gives you a second thread. For work that parallelises — independent tiles of an image, chunks of an audio buffer, rows of a matrix — a pool gives you as many as the machine has cores, and the structure is a small extension of the single-worker case:
const size = Math.max(1, Math.min(navigator.hardwareConcurrency - 1, 8));
const pool = Array.from({ length: size }, () =>
new Worker(new URL("./compute.worker.js", import.meta.url), { type: "module" }));
let next = 0;
export function submit(task) {
const worker = pool[next++ % pool.length]; // round-robin is fine for uniform tasks
return call(worker, task);
}
Each worker holds its own module instance and its own linear memory, so there is no shared state to
coordinate and no atomics to reason about — the tasks simply have to be independent. That is a much
lower-complexity form of parallelism than shared memory, and for embarrassingly parallel work it
performs comparably, because the only thing shared memory would save is the transfer of the task’s
data.
Reserve one core for the main thread rather than spawning hardwareConcurrency workers. Saturating
every core makes the page’s own rendering compete with the pool, and the interface becomes less
responsive even though total throughput is unchanged.
When shared memory is the better answer
The message-passing pool above copies or transfers each task’s data. That is free for a transferred
ArrayBuffer and cheap for small descriptors, but it does not work when several workers need to read
the same large dataset — sending a copy to each defeats the purpose, and transferring moves ownership
away from everyone else.
That is the case shared memory exists for: one SharedArrayBuffer visible to every worker, each
addressing a disjoint slice. The cost is cross-origin isolation, which the page must be served with,
and the need to coordinate with atomics wherever the slices are not disjoint. Both are real, which is
why message passing is the right default and shared memory the deliberate escalation — see
sharing memory between Wasm and Web Workers
for the shape it takes.
A reasonable progression is: one worker to unblock the main thread, a pool when the work parallelises and the data is per-task, shared memory only when the data is shared and large. Most applications stop at the second step and lose nothing by it.
Frequently Asked Questions
Can the worker and the page share the module’s memory?
Only if the memory is shared: true and the page is cross-origin isolated. Without that, each
instance’s memory is private and results cross by message.
Should I compile in the worker and use the module on the page? That is a different pattern with a different goal — see compiling Wasm in a worker. Keeping the instance in the worker is better when the compute is long; transferring the module is better when only the compilation was the problem.
Does wasm-bindgen glue work inside a worker?
Yes. Import the generated module in the worker and call init() there; it will fetch its own binary,
which is why passing the URL explicitly matters when the bundler has hashed it.
Should the worker be terminated when the feature is closed? Only if it will not be used again soon. Termination frees its memory immediately, which matters for a large instance, but restarting costs the spawn plus the compile again. For a feature a user toggles repeatedly, keeping the worker alive is usually the better trade.
Do module workers work everywhere? In every current browser, yes; older ones need a classic worker and a bundled script.
Related
- ESM bindings & module generation — producing the module this worker loads.
- Bundling Wasm ESM with Vite — the
?urlimport and hashed asset handling. - Compiling Wasm in a worker to free the main thread — the compile-only variant.
- Avoiding copies when passing image buffers — minimising what crosses per task.
← Back to ESM Bindings & Module Generation