Wrapping a Wasm Worker with Comlink
This page answers one task: move a WebAssembly module into a Web Worker so its work does not block the page, and call it from the main thread
as if it were a local async API — without writing a postMessage protocol by hand.
Prerequisites
- [ ] A module and its glue that work on the main thread today (wasm-pack
webtarget, Emscripten ES module, or hand-written). - [ ] Comlink (
npm install comlink), about 1 KB gzipped. - [ ] A bundler or setup that supports module workers (
new Worker(url, { type: "module" })).
The problem Comlink removes
Running Wasm in a worker is the standard way to keep heavy computation off the main thread, as explained in
keeping the UI responsive during long Wasm tasks.
The cost is plumbing. Workers communicate only through postMessage, so every call needs a message type, an id to match the response, error
propagation, and handling for transferable buffers. A module with a dozen exports turns into a dozen message types and a switch statement on each side.
Comlink replaces that plumbing with RPC over postMessage. The worker exposes an object; the main thread wraps the worker and gets a proxy whose
methods return Promises. Calls, arguments, return values and thrown errors cross automatically. The worker’s API looks like a normal async object, and
the Wasm module stays exactly where it should be — off the main thread.
Step 1 — expose an API from the worker
Load the module once in the worker, then expose an object whose methods do the boundary work — copying into linear memory, calling exports, copying results out:
// image.worker.js
import * as Comlink from "comlink";
import init, { resize_rgba } from "./pkg/imagekit.js";
const ready = init(); // load and instantiate once
const api = {
async resize(bytes, width, height, outWidth, outHeight) {
await ready;
const out = resize_rgba(bytes, width, height, outWidth, outHeight); // wasm-bindgen copies in/out
return Comlink.transfer(out, [out.buffer]); // move, do not copy, back
},
async version() {
await ready;
return "imagekit 2.4.0";
},
};
Comlink.expose(api);
Keep the exposed API coarse and data-oriented — one call per image, not one per pixel — because each call is a message round trip. Return plain data (typed arrays, numbers, strings, plain objects), which structured cloning handles.
Step 2 — wrap the worker on the main thread
// main.js
import * as Comlink from "comlink";
const worker = new Worker(new URL("./image.worker.js", import.meta.url), { type: "module" });
const imagekit = Comlink.wrap(worker);
const input = new Uint8Array(await file.arrayBuffer());
const resized = await imagekit.resize(Comlink.transfer(input, [input.buffer]), 4000, 3000, 800, 600);
imagekit.resize returns a Promise; the page stays responsive while the worker computes. Comlink.transfer marks buffers as transferables so they
move between threads instead of being copied — essential for large images. After transfer, the main thread’s input is detached and cannot be
used again, which is usually fine for data that is being handed off. The mechanics are covered in
transferring ArrayBuffers to workers without copying.
Step 3 — report progress with a proxied callback
Functions cannot be structured-cloned, but Comlink can proxy one: the worker receives a stub that, when called, posts a message back to the main thread, which calls the real function. That is a clean way to report progress from long Wasm operations:
// main thread
await imagekit.processBatch(files, Comlink.proxy((done, total) => {
progress.value = done / total;
}));
// worker
async processBatch(files, onProgress) {
await ready;
for (let i = 0; i < files.length; i++) {
results.push(process_one(files[i]));
await onProgress(i + 1, files.length); // a cross-thread call; await keeps it ordered
}
return results;
}
Call the proxied callback at a coarse granularity — per file, per chunk — since each call is a message. Fine-grained progress from inside a Wasm loop is better reported through shared memory, as described in reporting progress from Wasm to the UI.
Step 4 — handle errors and traps
If a Wasm export traps or the worker code throws, Comlink serialises the error and rejects the main thread’s Promise with it. Catch it like any other async error:
try {
await imagekit.resize(bytes, w, h, 800, 600);
} catch (err) {
console.error("resize failed:", err.message); // e.g. "RuntimeError: unreachable" from a Rust panic
}
A trap may leave the module’s state inconsistent, so after a RuntimeError treat the worker as suspect: either re-instantiate the module inside it or
terminate the worker and create a new one, as discussed in
recovering a module after a trap.
Step 5 — clean up
Workers hold memory — including the module’s linear memory — until terminated. When the feature is no longer needed, release the proxy and terminate the worker:
imagekit[Comlink.releaseProxy]();
worker.terminate();
For pages that use the worker throughout their life, create it once at startup and keep it; creating a worker and instantiating the module costs tens of milliseconds, which is too much to pay per call.
Running several jobs at once
A single worker processes calls one at a time: while it is inside a Wasm export, later calls queue up in its message queue. For an application that submits many independent jobs — thumbnails for a folder of photos, a batch of documents to parse — one worker becomes a bottleneck, and a small pool of workers wrapped with Comlink is the natural next step.
const size = Math.min(navigator.hardwareConcurrency || 4, 4);
const pool = Array.from({ length: size }, () =>
Comlink.wrap(new Worker(new URL("./image.worker.js", import.meta.url), { type: "module" })));
let next = 0;
const resize = (...args) => pool[next++ % size].resize(...args); // round-robin
const thumbs = await Promise.all(files.map(async (f) => resize(await toBytes(f), 4000, 3000, 320, 240)));
Each worker has its own module instance and its own memory, so jobs do not interfere, and the pool keeps several cores busy. Round-robin is enough when jobs are similar in size; for uneven jobs, a queue that hands the next job to whichever worker finishes first balances load better. Keep the pool small — each worker costs its own instance memory — and compile the module once and post it to the workers rather than letting each compile its own copy, which is the technique in instantiating one module many times.
When to use Comlink and when to write messages by hand
Comlink is the right default for request-response APIs: call a function, get a result. It is less suited to two situations. High-frequency messaging —
hundreds of small messages per second, such as streaming audio frames or per-frame game state — pays Comlink’s small overhead on every message and
benefits from a hand-written protocol or shared memory. And designs where the worker pushes data to the main thread on its own schedule, rather than
in response to calls, map more naturally onto postMessage events or a ReadableStream. For everything else — image processing, parsing, encoding,
search, inference requests — Comlink keeps the worker boundary nearly invisible in application code.
Expected output
The page stays responsive while a 12-megapixel image is resized in the worker; the call resolves with a Uint8Array of 800×600×4 bytes, and the
main thread’s Performance timeline shows no long task during the resize.
Gotchas
DataCloneError: function could not be cloned. A function was passed withoutComlink.proxy. Wrap callbacks.- Large buffers copied instead of moved. Arguments not marked with
Comlink.transferare cloned. Transfer large data. - Using a transferred buffer afterwards. It is detached and has length zero. Keep a copy if you need the data on both sides.
- Pooling without bounding memory. Every worker holds an instance and its memory. Size the pool for memory as well as cores.
- Calling before the module is ready. Await the init promise inside each method, as above, rather than assuming it has finished.
Performance note
A Comlink round trip with small arguments cost about 0.1–0.2 ms in Chrome. Resizing a 48 MB image took 410 ms in the worker; transferring the input and output buffers instead of cloning them saved about 70 ms of copying, and the main thread stayed free throughout.
Frequently Asked Questions
Does Comlink work with Emscripten modules? Yes — load the Emscripten factory inside the worker and expose functions that call into it, exactly as with wasm-bindgen.
Can several workers share one module?
Each worker has its own instance. To share compiled code, compile once and post the WebAssembly.Module; see
instantiating one module many times.
Does Comlink work in Node worker threads?
Yes, with Comlink’s Node adapter for worker_threads.
Can the worker call back into the main thread for data? Yes, with a proxied function — but each call is a round trip. Pass the data the job needs up front where possible.
What about TypeScript?
Comlink.wrap<typeof api>(worker) gives a typed proxy whose methods return Promises of the exposed functions’ return types.
Related
- Loading Wasm in a Web Worker with ESM — getting the module into the worker.
- Designing a promise-based API around a Wasm module — the API shape to expose.
- Cancelling long-running Wasm work — stopping a call already in progress.
- Resizing images off the main thread — the full workload this example comes from.
← Back to Async & Event-Loop Integration