Comlink vs Hand-Rolled postMessage

Both approaches send the same message over the same channel; the difference is who writes the envelope, and what that choice costs you when the traffic stops looking like a function call.

This comparison belongs to Comlink & RPC Patterns, part of the Web Workers Architecture & Communication reference. The short version: RPC wins for request/response APIs, which is most worker traffic; a hand-written envelope wins when the traffic is a stream, when messages need routing or priorities, or when nothing may be added to the bundle.


The Same Feature, Both Ways

A worker that geocodes a batch of addresses. Hand-rolled first:

// worker.ts — hand-written
type Req = { id: number; kind: 'GEOCODE'; addresses: string[] };
type Res = { id: number; ok: true; points: Float64Array } | { id: number; ok: false; error: string };

self.addEventListener('message', async (event: MessageEvent<Req>) => {
  const { id, kind, addresses } = event.data;
  if (kind !== 'GEOCODE') return;
  try {
    const points = await geocode(addresses);
    (self as DedicatedWorkerGlobalScope).postMessage(
      { id, ok: true, points } satisfies Res, [points.buffer]);
  } catch (error) {
    (self as DedicatedWorkerGlobalScope).postMessage(
      { id, ok: false, error: (error as Error).message } satisfies Res);
  }
});

The page side then needs the correlation map, the pending-promise table, the rejection path and a matching Req/Res import — roughly 40 more lines before a single address is geocoded. The RPC version is the whole feature:

// worker.ts — Comlink
import * as Comlink from 'comlink';

const api = {
  async geocode(addresses: string[]): Promise<Float64Array> {
    const points = await geocode(addresses);
    return Comlink.transfer(points, [points.buffer]);
  },
};
export type GeoApi = typeof api;
Comlink.expose(api);
// page.ts
const geo = Comlink.wrap<GeoApi>(new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }));
const points = await geo.geocode(addresses);   // throws here if the worker threw
Code the project owns for one request-response capability Two stacked bars comparing lines of project-owned transport code for a single geocoding capability. The hand-written version is about seventy-five lines: fifteen for the request and response type union, twenty for the correlation map and pending promises, twenty-five for the two switch statements, ten for error stringifying and rethrow, and five for the actual call. The Comlink version is about ten lines: two for expose, one for wrap, one for the call, and six for the API object, with the envelope, correlation, settlement and error rethrow supplied by a one-point-one kilobyte runtime shown as a separate shaded segment that the project does not maintain. Transport code the project maintains, for one capability hand-written types 15 correlation 20 two switches 25 errors 10 call 5 ≈ 75 Comlink API 10 runtime, not yours ≈ 10 lines + 1.1 kB 0 25 50 75 lines The olive segments are domain code. Everything pumpkin is machinery that a proxy generates.
The line count matters less than what those lines are: correlation and error plumbing is where hand-written boundaries accumulate their bugs, and it is exactly the part RPC deletes.

Where Each One Wins

Dimension Hand-rolled postMessage Comlink proxy
Bundle cost 0 kB (plus your own ~1–2 kB of helpers) 1.1 kB gzipped
Per-call overhead hop only (0.1–0.5 ms) hop + 0.02–0.05 ms
Type safety manual union, drifts silently one exported type, compiler-checked
Errors strings unless you build more Error rethrown with stack
Streaming / many replies per call natural needs a callback proxy per stream
Priorities, cancellation, routing full control over the queue must be modelled as API methods
Reading a message in DevTools your own shape, self-describing { type: 'APPLY', path, argumentList, id }
Time to first working call an afternoon minutes

Two rows deserve elaboration because they are where teams get surprised.

Streaming. RPC is request/response by construction. One call, one reply. Progress and incremental results have to travel through a callback proxy, and each invocation of that callback is its own hop — fine at 20–100 updates per job, wasteful at 10,000. Traffic that is genuinely a stream, with backpressure and completion semantics, is better modelled directly; that is the subject of Streaming & Backpressure Across Threads.

Cancellation and priority. A proxied call is a promise, and a promise cannot be cancelled. If your worker must abandon in-flight work when the user types another character, you need either an explicit cancel(token) method on the API — which only helps if the worker’s loop actually checks the token — or a hand-written queue that can drop pending requests before they are ever dispatched. Both are possible with RPC; neither is free with it.

Choosing between an RPC proxy and a hand-written envelope A decision tree. The first question asks whether the traffic is request and response, meaning one reply per call. If it is not — a stream of results, or messages the worker sends unprompted — the answer is a hand-written envelope, optionally with a callback proxy for occasional progress. If it is request and response, the next question asks whether calls need cancellation, priorities or routing across several workers. If they do, the recommendation is a hand-written dispatcher, or a proxy placed in front of a pool. If they do not, the last question asks whether the bundle budget can absorb one point one kilobytes; yes leads to Comlink, and no leads to a minimal hand-written envelope of about forty lines. Which boundary style fits this worker? One reply per call? no — a stream yes Hand-written envelope chunk messages, credits, explicit end-of-stream marker Cancellation, priorities or routing? yes no Own the dispatcher a queue you can reorder and drop — or a proxy over a pool Comlink 1.1 kB buys back ~75 lines per capability Nothing forces one answer per project: a proxy for the request-response API and a raw port for the streaming channel coexist on the same worker, since expose ignores foreign messages.
Most applications land on the right-hand leaf for their API surface and keep one hand-written channel for whatever streams. Mixing is the normal outcome, not a compromise.

Four Shapes Where Hand-Rolled Still Wins

1. The worker talks first. A worker that watches a SharedArrayBuffer, drains a queue, or wakes on a timer sends messages nobody asked for. RPC has no vocabulary for that: every message is a reply to a call. You can invert it with a long-lived callback proxy — the page hands the worker a function and the worker calls it forever — but that is a hand-written notification channel wearing a proxy costume, and it is simpler to write the channel.

2. Ordering and priority matter. A search-as-you-type box wants the newest query and none of the previous four. With a proxy, all five calls are already in the port’s queue and the worker will run all five, because a promise cannot be withdrawn. A dispatcher you own can drop superseded requests before dispatch and never spend the CPU at all. The same argument applies to any workload where a late result is worthless.

3. The message is the data. When the payload is one ArrayBuffer per frame and the “call” is here is frame N, wrapping it in a function-call abstraction adds a path array, an argument list and a reply message to something that needed one postMessage and no reply. Ring-buffer and frame-pump designs live at this end — see SharedArrayBuffer & Atomics for the version where even the message goes away.

4. Nothing may be added to the bundle. 1.1 kB is small, but a library embedded in a third-party widget, a payment SDK or an ad-tech tag is often held to a budget that has no room at all for a dependency. A 40-line envelope with a Map and a counter is unglamorous and free.

Outside those four shapes, the hand-written version is usually a re-implementation of the proxy with fewer features and no tests.


A Migration Recipe

Migrating an existing worker does not require a rewrite, because both mechanisms coexist in one scope.

  1. Add Comlink.expose(api) beside the existing listener. Start api as an empty object. The existing addEventListener('message', …) handler is untouched and keeps serving current traffic, since Comlink ignores messages that lack its own envelope shape.
  2. Move one capability. Pick the one with the most call sites and the least streaming behaviour. Implement it as a method on api, and have the old switch case delegate to that method so both paths share one implementation.
  3. Switch the callers. Replace call({ kind: 'GEOCODE', … }) with await geo.geocode(…). The compiler finds the ones you missed if the old helper’s request union loses its member in the same commit.
  4. Delete the case, then the union member. Only after the last caller is gone. Leaving a dead switch case is how a project ends up shipping both protocols indefinitely.
  5. Check the bundle. Confirm the old helper actually left the page chunk; a single stray import keeps it alive with no compile error.
  6. Add the supervisor. Before the migration is called done, wrap the proxy so a worker crash rejects in-flight calls — the behaviour you had for free with the hand-written table, and the one thing the proxy does not replace.

The migration path from a hand-written envelope to a proxy Four steps in order. Expose an empty API object beside the existing message listener, which changes nothing because Comlink ignores foreign messages. Move one capability by implementing it as a method and delegating the old switch case to it. Switch the callers, letting the compiler find the ones that were missed. Delete the dead case and the union member, then check that the old helper actually left the page bundle. Migrating one capability at a time expose beside an empty API object foreign messages ignored move one method the old case delegates one implementation switch callers the compiler finds misses one commit per area delete the case and the union member check the bundle Both mechanisms coexist in one worker scope, so no step needs a flag day. Each step is cheap; skipping one is what makes the next expensive.
The last step is the one teams skip, and skipping it is how a project ships two protocols indefinitely.

Gotchas When Migrating

DevTools message inspection gets less readable. A hand-written message says { kind: 'GEOCODE', addresses: [...] }; a Comlink message says { type: 'APPLY', path: ['geocode'], argumentList: [{ type: 'RAW', value: [...] }], id: 'a1b2' }. When you are debugging in the Network-less world of worker messages, that indirection costs a few seconds each time. Knowing the shape in advance is most of the fix — the techniques in Chrome DevTools Worker Debugging apply unchanged, since the messages are still ordinary structured-cloneable objects.

An unhandled worker crash behaves differently. With a hand-written envelope you typically own an error listener that rejects everything pending. With a proxy, in-flight promises simply never settle after terminate() or a fatal worker error, so a supervisor that tracks and rejects them is not optional in production code.

Two exposures on one worker conflict. Calling Comlink.expose twice in the same global scope registers two listeners; both answer the same message, and the second reply is discarded or, worse, resolves a different call. Expose exactly one object — compose several modules into it if needed.

Bundle double-counting on incremental migration. During a migration, both the hand-written helper and the proxy exist. That is fine, but check the page chunk afterwards: the helper often lingers because one caller was missed, and it is invisible in a diff.

Message-ordering assumptions quietly change. A hand-written protocol usually has one queue, so replies come back in dispatch order and code accidentally depends on that. A proxy over several endpoints created with createEndpoint() has one queue per endpoint, and replies interleave. Nothing about that is wrong, but a component that assumed “the second result always arrives after the first” starts flickering. If order matters, make it explicit — sequence numbers in the payload, or a single endpoint for the ordered calls.

Structured-clone limits are unchanged, and now less visible. The hand-written version made the payload explicit at every postMessage, so a non-cloneable value was easy to spot in review. A proxy call reads like a local function call, and passing a DOM node, a Proxy, or a class instance with methods now throws from inside library code with a stack that starts in comlink.mjs. The remedy is the same as it always was — keep the argument types plain — but the review signal is weaker, so lean on the contract types described in Typed RPC Contracts with Comlink.


Performance Note

Measured on a 2023 laptop, Chrome 126, with a 64 kB argument and a 512 kB transferred reply: a hand-written envelope round trip averaged 0.31 ms, the equivalent Comlink call 0.35 ms — a 13% relative difference on a sub-millisecond operation, invisible next to any real work the worker does. Over 10,000 calls the gap is 0.4 s of CPU spread across both threads; over the 20–200 calls a typical interaction makes, it is under 10 ms.

The number worth defending is therefore not per-call overhead but calls per interaction. A hand-written protocol tends to batch by default, because writing each new message kind is work; a proxy makes fine-grained calls effortless, which is exactly how a page ends up making 60 hops to render a table. Whichever you choose, meter the hop count and treat a regression in it as a bug.

Frequently Asked Questions

Is Comlink fast enough for a 60 fps render loop driven from a worker?
For the control messages, yes — the proxy adds 0.02–0.05 ms on top of a hop that costs 0.1–0.5 ms, so a per-frame call fits inside a 16.7 ms budget with room to spare. For the pixels, no transport choice saves you: move the rendering itself into the worker with OffscreenCanvas Rendering so nothing crosses per frame. A request-per-frame design is the problem, not the library that expresses it.
Can I adopt Comlink incrementally on a worker that already has a message protocol?
Yes. Comlink.expose installs its own message listener and ignores any message that is not a Comlink request, so an existing addEventListener('message', …) handler keeps working beside it. Migrate one capability at a time, and delete the hand-written envelope only when the last caller is gone.

See also