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
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.
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.
- Add
Comlink.expose(api)beside the existing listener. Startapias an empty object. The existingaddEventListener('message', …)handler is untouched and keeps serving current traffic, since Comlink ignores messages that lack its own envelope shape. - 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 oldswitchcase delegate to that method so both paths share one implementation. - Switch the callers. Replace
call({ kind: 'GEOCODE', … })withawait geo.geocode(…). The compiler finds the ones you missed if the old helper’s request union loses its member in the same commit. - Delete the case, then the union member. Only after the last caller is gone. Leaving a dead
switchcase is how a project ends up shipping both protocols indefinitely. - Check the bundle. Confirm the old helper actually left the page chunk; a single stray import keeps it alive with no compile error.
- 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.
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.