Comlink vs Raw postMessage for Web Workers: Which Abstraction to Pick
This comparison sits under offloading work to web workers with Comlink, part of the interactivity work in Core Web Vitals & Measurement: once you have decided to move heavy work off the main thread to protect INP under 200ms, you still have to choose how the main thread talks to the worker.
Both options run identical CPU off the main thread, so neither is "faster" at the work itself — the choice is about the communication layer. Comlink wraps the worker in a Proxy so await worker.parseJSON(text) reads like a local async call. Raw postMessage is the platform primitive: you send a message, the worker replies with another message, and you wire up the correlation yourself. The decision turns on call complexity, bundle budget, per-call overhead, and how much debugging friction you can absorb.
Decision Matrix
| Dimension | Comlink | Raw postMessage |
|---|---|---|
| Ergonomics | High — proxied async methods, return values, thrown errors propagate as rejections | Low — manual message protocol, request/response correlation, hand-rolled error handling |
| Bundle size | +~1.1KB gzipped (both threads) | 0 bytes — platform built-in |
| Per-call overhead | One Proxy hop + an internal message-id round-trip on top of the post | Bare postMessage cost only |
| Multiple methods | Trivial — expose an object of functions | Verbose — a type switch grows with every method |
| Transferables | Supported via Comlink.transfer(value, [buf]) | Native — second arg to postMessage(msg, [buf]) |
| Callbacks / progress | Comlink.proxy(fn) proxies a callback into the worker | Manual back-messages with your own correlation |
| Debugging | One extra abstraction in stack traces; needs worker source maps | Transparent — messages are visible and inspectable |
| Type safety | Strong with TypeScript — Comlink.Remote<T> types the proxy | Weak — message payloads are untyped unless you hand-write guards |
| Best fit | Rich, multi-method worker APIs called from app code | One hot function, or a tight high-frequency message loop |
Ergonomics: Where Comlink Earns Its Place
The gap is starkest with more than one method. Compare a two-method worker.
// Comlink: the worker API is just an object; the main thread awaits methods.
import * as Comlink from 'comlink';
Comlink.expose({
parse: (text) => JSON.parse(text),
hash: (buf) => crypto.subtle.digest('SHA-256', buf),
});
// main thread: const api = Comlink.wrap(worker); await api.parse(text);
// trade-off: the Proxy hides the boundary so well that engineers forget every call is
// async + cloned, and start calling it in tight loops — keep calls coarse-grained.
// Raw postMessage: you own the protocol, the id correlation, and the error path.
const pending = new Map();
let seq = 0;
function call(method, args, transfer = []) {
const id = seq++;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
worker.postMessage({ id, method, args }, transfer);
});
}
worker.onmessage = ({ data }) => {
const p = pending.get(data.id); pending.delete(data.id);
data.error ? p.reject(data.error) : p.resolve(data.result);
};
// trade-off: this is ~30 lines you now own and test forever; it's the right cost only
// when you have ONE worker call and don't want a dependency, or you need full control.
The raw version is essentially a hand-rolled subset of what Comlink ships in 1.1KB. The moment a worker grows past one or two methods, the maintenance cost of the manual protocol exceeds the bundle cost of Comlink.
The hidden cost of the raw protocol is correctness, not just keystrokes. The pending map, the sequence counter, and the error-path branch are each places a real bug lives: a leaked map entry when a worker never replies, a swallowed rejection when data.error is set but falsy, an off-by-one if two callers race the counter. These are the bugs that ship quietly and surface as a stuck spinner in production. Comlink has paid down that surface once, with tests, for every consumer. Writing it again per project means re-testing it per project — and most teams do not, which is how a worker layer accumulates intermittent hangs nobody can reproduce.
Overhead: When the Proxy Hop Matters
Comlink's per-call cost is the Proxy trap plus its internal message-id bookkeeping layered on the same structured-clone postMessage that raw code uses. For coarse-grained calls — one await api.parse(bigText) that runs 300ms off-thread — the overhead is unmeasurable noise. It only matters in a high-frequency loop: thousands of tiny round-trips per second, such as a per-frame physics step or a streaming protocol pushing many small messages. There, the abstraction's fixed per-message cost compounds and a hand-tuned raw postMessage (or a MessageChannel with a binary protocol) wins. Both abstractions still pay the same structured-clone cost for non-transferable payloads, so the marshalling discipline from the parent guide on transferables applies either way.
Measuring the dispatch cost yourself
If you suspect the message layer is hot, measure it rather than guess. Time a no-op round-trip on both abstractions over many iterations and compare against the cost of the actual work.
async function benchRoundTrip(callFn, n = 1000) {
const start = performance.now();
for (let i = 0; i < n; i++) await callFn(); // a no-op worker method
return (performance.now() - start) / n; // mean ms per round-trip
// trade-off: this measures dispatch with an empty payload, so it ISOLATES the proxy
// overhead but ignores clone cost — re-run with a realistic payload before deciding,
// because for real work the clone usually dwarfs the proxy hop on both abstractions.
}
In almost all product workloads the per-call mean is a fraction of a millisecond on both, and the 300ms of actual compute makes the difference irrelevant. The dispatch cost only becomes the deciding factor at thousands of calls per second — at which point you should also ask whether batching many calls into one would beat both abstractions.
Debugging and Transferables
Raw postMessage is more transparent to debug: every message is a plain object you can log at both ends, and a stack trace points directly at your onmessage handler. Comlink adds a Proxy layer, so a thrown worker error surfaces as a rejected promise with a stack that runs through Comlink's internals — workable, but you must ship source maps for the worker chunk to read it. On transferables the two are equivalent in capability: raw code passes the transfer list as the second postMessage argument; Comlink wraps the value in Comlink.transfer(value, [buffer]). Neither can transfer a plain string or object — those are always cloned — so for binary-heavy work both require you to think in ArrayBuffers regardless of abstraction.
There is one capability gap worth knowing: Comlink can proxy a callback into the worker with Comlink.proxy(fn), so the worker can invoke a main-thread function for progress events without you writing any back-channel protocol. Reproducing that with raw postMessage means inventing a second message type for progress, correlating it to the originating request, and routing it back to the right caller — exactly the kind of bookkeeping Comlink exists to remove. If your worker needs to stream progress, Comlink's ergonomic lead widens considerably; if it is strictly request/response, the two converge.
Type safety is the other axis where the abstractions diverge in a TypeScript codebase. Comlink.wrap<T>() returns a Comlink.Remote<T> that types every proxied method, its arguments, and its return value as a promise, so a signature change in the worker surfaces as a compile error at the call site. Raw postMessage payloads are any unless you hand-write discriminated-union message types and runtime guards, and a drifted message shape fails silently at runtime instead of at build time. For a long-lived worker API that multiple teams call, that static guarantee is often the deciding factor on its own.
When to Pick Which
Pick Comlink when the worker exposes several methods, you call it from application code that benefits from reading like normal async functions, you use TypeScript and want the proxy typed, or you need to proxy callbacks for progress. This covers the large majority of product work — a JSON-parsing worker, an image-processing service, a search-index builder. The 1.1KB is trivial against the maintainability it buys, and the bundling story is identical to the manual approach since you wire the worker the same way described in dynamic imports and route-based splitting.
Pick raw postMessage when you have exactly one fire-and-forget function and refuse a dependency, when you are in a hot path doing thousands of tiny messages per second where the proxy overhead compounds, or when you need a custom wire protocol (binary framing, a SharedArrayBuffer ring buffer) that does not map to method-call semantics at all. It is also the right call when total transparency for debugging outweighs ergonomics — low-level infrastructure where you want to see every byte on the wire.
A practical hybrid: start with Comlink for the developer experience, and if profiling later shows the message layer itself is hot — which is rare, because the work dominates, not the dispatch — drop to raw postMessage for just that one hot path. Decide based on a real trace, not a guess; the profiling event handlers for INP workflow shows how to confirm whether the dispatch or the compute is actually the cost. For borderline cases where the work might not need a worker at all, re-check whether scheduler.yield() on the main thread already solves it before adopting either abstraction.
Migrating from raw postMessage to Comlink
If you started with raw postMessage and the protocol has grown unwieldy, the migration is mechanical and low-risk because the worker still runs identical compute — only the wire wrapper changes. Replace the worker's onmessage switch with a Comlink.expose({ ... }) object of the same functions, and replace the main thread's hand-rolled call() helper and pending map with Comlink.wrap(worker).
// Before (worker): const handlers = { parse, hash }; onmessage = e => { ... dispatch ... }
// After (worker):
import * as Comlink from 'comlink';
Comlink.expose({ parse, hash }); // the same two functions, no protocol code
// trade-off: migrating mid-project means both styles coexist briefly; do it per-worker
// in one commit rather than half-converting a single worker, or message routing breaks.
The reverse migration — Comlink to raw — is only worth doing for a measured hot path, and you typically extract just that one method to a dedicated raw channel while leaving the rest on Comlink. Wholesale removal of Comlink to "save 1.1KB" is rarely justified against the protocol code you take back on.
Related
- Offloading work to web workers with Comlink — the full setup, transferables, lifecycle, and pooling behind both options here.
- Moving heavy JSON parsing off the main thread — a concrete worker the matrix above applies to directly.
- Optimizing INP with scheduler.yield() — the on-thread alternative to check before reaching for any worker.
- Profiling event handlers for INP — proving whether dispatch or compute is the real cost before optimizing the message layer.