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.

Comlink proxy vs raw postMessage protocol Comlink exposes an object of methods called as async functions; raw postMessage needs a manual id-correlated message switch. Two ways across the worker boundary Comlink Raw postMessage await api.parse(text) methods read as local +1.1KB, 1 async hop errors reject promise typed proxy postMessage + onmessage manual id correlation 0 bytes, full control hand-rolled errors untyped payloads Same off-thread compute and clone cost; only the communication layer differs.

Decision Matrix

DimensionComlinkRaw postMessage
ErgonomicsHigh — proxied async methods, return values, thrown errors propagate as rejectionsLow — manual message protocol, request/response correlation, hand-rolled error handling
Bundle size+~1.1KB gzipped (both threads)0 bytes — platform built-in
Per-call overheadOne Proxy hop + an internal message-id round-trip on top of the postBare postMessage cost only
Multiple methodsTrivial — expose an object of functionsVerbose — a type switch grows with every method
TransferablesSupported via Comlink.transfer(value, [buf])Native — second arg to postMessage(msg, [buf])
Callbacks / progressComlink.proxy(fn) proxies a callback into the workerManual back-messages with your own correlation
DebuggingOne extra abstraction in stack traces; needs worker source mapsTransparent — messages are visible and inspectable
Type safetyStrong with TypeScript — Comlink.Remote<T> types the proxyWeak — message payloads are untyped unless you hand-write guards
Best fitRich, multi-method worker APIs called from app codeOne hot function, or a tight high-frequency message loop

The gap is starkest with more than one method. Compare a two-method worker.

javascript
// 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.
javascript
// 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.

javascript
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.

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).

javascript
// 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.