Comlink & RPC Patterns
A worker API written by hand is mostly bookkeeping: a message type union, a correlation id, a Map of pending promises, a switch in the worker, and a matching switch on the way back. None of that is domain logic. Remote procedure calls delete it — const rows = await api.parseCsv(text) compiles down to the same envelope, generated instead of typed out. This topic is part of the Web Workers Architecture & Communication reference, and it covers the RPC layer end to end: how the proxy actually works, where it saves you, and the two places it will quietly make a page slower than the hand-written version it replaced.
The reference implementation throughout is Comlink, a 1.1 kB library from the Chrome team, because it is the smallest thing that gets the semantics right. Everything here transfers to any RPC layer built on the same idea — the design questions are about the call surface and the round-trip budget, not the library.
The Ceremony postMessage Demands
Start with the shape of the problem. A worker that parses spreadsheets needs three capabilities: parse a file, report progress, and return summary statistics. Written directly on postMessage, the transport code is longer than the parser:
// main.ts — hand-written envelope, abbreviated
type Request =
| { id: number; kind: 'PARSE'; text: string }
| { id: number; kind: 'STATS'; column: string };
const pending = new Map<number, (value: unknown) => void>();
let nextId = 0;
worker.addEventListener('message', (event: MessageEvent) => {
const { id, ok, value, error } = event.data;
const resolve = pending.get(id);
if (!resolve) return; // late reply after cancellation
pending.delete(id);
ok ? resolve(value) : resolve(Promise.reject(new Error(error)));
});
function call<T>(request: Omit<Request, 'id'>): Promise<T> {
const id = nextId++;
return new Promise<T>((resolve) => {
pending.set(id, resolve as (value: unknown) => void);
worker.postMessage({ id, ...request });
});
}
Every project that uses workers writes some version of this, and every version has the same four latent defects: the correlation map leaks when a worker is terminated mid-flight, the kind strings drift out of sync between the two files, errors arrive as strings with no stack, and there is no compile-time link between a request and the type of its reply. The Message Passing Strategies topic covers hardening that envelope by hand. RPC takes the other road: generate it.
Prerequisites
- A module worker.
new Worker(url, { type: 'module' })so the API type can be imported by both sides from one file. Classic workers work, but the shared type has to be duplicated, which reintroduces the drift RPC exists to prevent. - A bundler that understands
new URL('./worker.ts', import.meta.url). Vite, webpack 5, Rollup and esbuild all do; the wiring differences are in Bundling Module Workers with Vite and webpack. - TypeScript 4.5+, because the proxy type relies on conditional and recursive mapped types to turn synchronous method signatures into promise-returning ones.
- A measured baseline. Know the current main-thread cost of the work you are moving. RPC changes the ergonomics of the boundary, never the cost of crossing it.
- A plan for cancellation. A proxied call is a promise with no
abort(). Decide early whether cancellation is a separate method on the API or a terminate-and-respawn, as in Cancelling Worker Tasks with AbortSignal.
Step 1 — Define the API, Then Expose It
The API object is the contract. Write it as one exported object (or class) whose methods are coarse enough that a caller rarely needs two in a row.
// sheet-worker.ts
import * as Comlink from 'comlink';
export interface Summary {
rows: number;
columns: string[];
nulls: Record<string, number>;
}
const api = {
async parse(csv: string): Promise<Summary> {
const rows = csv.split('\n');
// …real parsing elided; see the CSV pipeline guide for the streaming version
return { rows: rows.length - 1, columns: rows[0].split(','), nulls: {} };
},
async histogram(column: string, bins: number): Promise<Uint32Array> {
return new Uint32Array(bins);
},
};
export type SheetApi = typeof api;
// Register the listener at module top level — before any call can arrive.
Comlink.expose(api);
Comlink.expose installs a message listener on the worker’s global scope and answers three kinds of request: GET (read a property path), APPLY (call a function at a path), and CONSTRUCT (new a class at a path). The exported SheetApi type is the only thing the main thread imports — a type-only import, so no worker code is pulled into the page bundle.
If expose runs after an await — behind a dynamic import(), a WASM instantiation, a config fetch — any call that arrives during that gap hits a scope with no listener and its promise never settles. Workers buffer nothing. Either expose immediately and have the methods await an internal ready promise, or gate the main thread on an explicit await api.ready() before anything else.
Step 2 — Wrap It and Call It
On the page, Comlink.wrap<T> returns a proxy typed as Remote<T>: every method returns a Promise, and every property read returns a Promise too.
// main.ts
import * as Comlink from 'comlink';
import type { SheetApi } from './sheet-worker';
const worker = new Worker(new URL('./sheet-worker.ts', import.meta.url), { type: 'module' });
const api = Comlink.wrap<SheetApi>(worker);
const summary = await api.parse(csvText); // one round trip
console.log(summary.rows); // plain object — already local
The mechanism is worth internalising, because every performance question about RPC follows from it. The proxy records the property path you touch (['parse']), and it does nothing at all until you invoke or await. Invocation posts { type: 'APPLY', path, argumentList }, the worker resolves the path against the exposed object, applies it, awaits the result, and posts the value back tagged with the same message id. What returns to your await is a structured clone of the return value — an ordinary local object, not a proxy — so reading summary.rows afterwards costs nothing.
await, one round trip.Comlink's own overhead is a path array plus a Reflect.apply: 0.02–0.05 ms per call on a 2023 laptop, measured against a hand-written envelope carrying the same payload. The postMessage hop it wraps costs 0.1–0.5 ms each way. Anything that makes RPC "slow" is therefore call count, not the library.
Step 3 — Keep the Call Surface Coarse
The proxy is a good abstraction and a bad one for the same reason: it makes a network hop look like a property read. Every additional await on the hot path is another 0.2–1 ms.
// ✗ Chatty — four round trips per file, ~1.2 ms of pure latency
const rows = await api.rowCount();
const cols = await api.columnNames();
const nulls = await api.nullCounts();
const dupes = await api.duplicateKeys();
// ✓ Coarse — one round trip, same data
const { rows, cols, nulls, dupes } = await api.describe();
The rule that survives contact with real code: a proxy call should be a unit of work, not a unit of data. If two calls are always made together, they are one method. If a loop body contains an await on a proxy, hoist the whole loop into the worker and pass the array.
Where the sequence genuinely is dynamic, Promise.all recovers most of the loss, because the calls pipeline — the worker processes them back to back while the page waits once:
// Four hops in flight at once: ~0.4 ms wall clock instead of ~1.2 ms.
const [rows, cols, nulls, dupes] = await Promise.all([
api.rowCount(), api.columnNames(), api.nullCounts(), api.duplicateKeys(),
]);
Step 4 — Send Progress Back with a Callback Proxy
A function cannot be structured-cloned, so passing one as an argument throws DataCloneError. Comlink.proxy wraps it in the mirror image of the main mechanism: the function stays on the page, a MessagePort is sent in its place, and the worker receives a proxy whose invocation posts a message back through that port.
// main.ts — a long parse that reports progress without polling
const onProgress = Comlink.proxy((done: number, total: number) => {
progressBar.value = done / total;
});
try {
const summary = await api.parseWithProgress(csvText, onProgress);
render(summary);
} finally {
onProgress[Comlink.releaseProxy](); // closes the port; the worker entry goes away
}
// sheet-worker.ts — the worker sees an ordinary async function
async parseWithProgress(
csv: string,
report: (done: number, total: number) => Promise<void>,
): Promise<Summary> {
const lines = csv.split('\n');
for (let i = 0; i < lines.length; i++) {
parseLine(lines[i]);
// One hop per call — report on a stride, never per row.
if (i % 5_000 === 0) void report(i, lines.length);
}
return summarise();
}
Two details decide whether this pattern helps or hurts. First, the worker’s view of the callback returns a promise, because invoking it is itself a hop; void-ing it (rather than awaiting) keeps the parse loop from stalling on a UI update that nobody is waiting for. Second, the stride matters more than anything else on the page: reporting every row on a 400,000-row file queues 400,000 messages, and the page spends longer draining that queue than the worker spent parsing. A stride that produces 20–100 updates over the life of the job is enough for a progress bar that looks smooth at 60 fps.
Each Comlink.proxy(fn) allocates a MessageChannel and registers an entry on both sides. Creating one per render — in a React effect with a changing dependency, for instance — leaks a port pair per pass, and the symptom is a worker heap that climbs in a staircase with no obvious retainer. Create the proxy once per job and release it in a finally.
Advanced: Teach the Proxy About Your Own Types
Structured clone flattens class instances into plain objects: methods disappear, prototypes are lost, and a Decimal arrives as { digits, exponent }. Rather than pushing conversion into every method, register a transfer handler once and let the proxy translate at the boundary in both directions.
// shared/transfer-handlers.ts — imported by BOTH the page and the worker
import * as Comlink from 'comlink';
import { Matrix } from './matrix';
Comlink.transferHandlers.set('matrix', {
// Does this value need special treatment?
canHandle: (value): value is Matrix => value instanceof Matrix,
// Value -> [serialisable payload, transfer list]
serialize: (m: Matrix) => [{ rows: m.rows, cols: m.cols, data: m.data }, [m.data.buffer]],
// Payload -> value, on the receiving side
deserialize: (p: { rows: number; cols: number; data: Float64Array }) =>
new Matrix(p.rows, p.cols, p.data),
});
Both sides must import that module before any call is made, since a handler registered on one side only produces a payload the other side cannot interpret — a silent shape mismatch rather than an error. The pay-off is that the API signature becomes honest: multiply(a: Matrix, b: Matrix): Promise<Matrix> means what it says, the underlying Float64Array moves by transfer instead of being copied, and no caller has to remember a conversion step. The same mechanism is how Comlink implements proxy() and transfer() internally — they are just two handlers registered by default.
Data-Transfer Strategy Under RPC
RPC changes the syntax of the boundary, not its physics. Arguments and return values are structured-cloned by default, which is exactly right for the kilobyte-scale objects that make up most API traffic and exactly wrong for pixel buffers. Comlink exposes the same three choices the raw API has:
| Payload on this call | Mechanism | How it looks in Comlink | Cost |
|---|---|---|---|
| Config, ids, small records (< 50 kB) | structured clone | plain argument | ~0.05 ms per 10 kB |
| Pixel data, decoded audio, column arrays | transfer | Comlink.transfer(view, [view.buffer]) |
O(1); sender’s view detaches |
| State two agents read concurrently | shared memory | pass a SharedArrayBuffer argument |
O(1) map, needs isolation headers |
| Progress, streaming updates | callback proxy | Comlink.proxy(fn) |
one hop per invocation |
Transfer through a proxy is a one-word change at the call site and identical in behaviour to a raw transfer — the buffer is detached in the caller the moment the message is posted, with the same detached-buffer failure catalogued in Detached Buffer Errors and How to Avoid Them:
const pixels = new Uint8ClampedArray(width * height * 4);
// Move the bytes; `pixels` is detached (byteLength 0) as soon as this line runs.
const out = await api.sharpen(Comlink.transfer(pixels, [pixels.buffer]), 1.4);
Returning bytes works the same way from inside the worker: return Comlink.transfer(result, [result.buffer]). For anything shared rather than handed over, a SharedArrayBuffer passes through a proxy call untouched, since it is cloneable-but-not-transferable — see SharedArrayBuffer & Atomics for the coordination rules that then apply.
Passing a SharedArrayBuffer as an RPC argument does not exempt the page from cross-origin isolation. The document must serve Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, or the constructor is undefined and the argument cannot exist in the first place.
Verification & Measurement
Two numbers tell you whether an RPC layer is healthy: calls per user action and milliseconds of latency per call. Both are trivial to capture by instrumenting the endpoint rather than the API, so the count is honest even when calls originate deep in component code.
// rpc-meter.ts — wrap the endpoint, not the API, so nothing can bypass it.
export function meteredEndpoint(worker: Worker): Worker {
const original = worker.postMessage.bind(worker);
let calls = 0;
worker.postMessage = ((message: unknown, ...rest: unknown[]) => {
calls++;
performance.mark(`rpc-${calls}`);
return original(message as never, ...(rest as []));
}) as Worker['postMessage'];
worker.addEventListener('message', () => {
performance.measure('rpc-round-trip', `rpc-${calls}`);
});
return worker;
}
With that in place, performance.getEntriesByName('rpc-round-trip') gives a distribution you can assert on in CI: a p95 above ~1 ms usually means the worker is busy rather than the transport slow, because a hop across an idle boundary does not vary much. Correlate with a profile — the technique in Profiling Worker CPU Usage with the Chrome Performance Tab — and the two cases separate cleanly: a busy worker shows the reply mark landing after a long task on the worker track, while a chatty page shows dozens of short marks with idle gaps between them.
The call count is the more actionable metric. Log it per interaction in development and fail the build if opening a document costs more than a handful of hops.
Failure Modes & Error Handling
Rejections cross, stacks do not. Comlink catches a thrown error in the worker, serialises name, message and stack as strings, and reconstructs an Error on the page. The reconstructed stack references worker file positions, which is useful in a source-mapped build and confusing in a minified one — see Structured Error Serialization Across Threads for a custom transfer handler that preserves error subclasses and cause.
Non-cloneable arguments throw synchronously. Passing a DOM node, a function (unwrapped), a Proxy, or a class instance with methods produces a DataCloneError at the call site, not inside the worker. Functions are the common case, and the fix is Comlink.proxy.
A terminated worker leaves promises pending forever. worker.terminate() does not reject in-flight calls; those promises simply never settle, and any await behind them stalls. Wrap the proxy in a supervisor that tracks pending calls and rejects them on termination — the same supervisor sketched in Restarting Crashed Workers with Exponential Backoff.
Proxies pin memory until released. Every Comlink.proxy(fn) and every remote instance holds an entry in the worker’s proxy table. Release them explicitly:
const session = await new remote.Session(config); // CONSTRUCT — a live proxy
try {
await session.run();
} finally {
session[Comlink.releaseProxy](); // free the worker-side entry
}
self.onerror still matters. An error thrown outside a call — during module evaluation, in a timer, in a stream callback — has no promise to reject into. Keep a plain worker.addEventListener('error', …) alongside the proxy so those surface at all, as in Fixing Uncaught Exceptions in Dedicated Workers.
Browser Compatibility
Comlink is built on postMessage, Proxy and structured clone, so support follows the platform rather than the library.
| Capability | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
Proxy + structured clone (core RPC) |
49+ | 18+ | 10+ | 79+ |
Module workers (type: 'module') |
80+ | 114+ | 15+ | 80+ |
MessageChannel endpoints (createEndpoint) |
✅ | ✅ | ✅ | ✅ |
Transferring OffscreenCanvas through a call |
69+ | 105+ | 16.4+ | 79+ |
SharedArrayBuffer arguments |
92+ (isolated) | 79+ (isolated) | 15.2+ (isolated) | 92+ (isolated) |
The only real portability trap is module-worker support: Firefox shipped it in 114, so a project supporting older Firefox needs a classic-worker build and a duplicated API type, or a bundler that inlines the worker as a classic script.
Advanced: One Worker, Many Consumers
A single exposed API can serve several independent consumers through Comlink.createEndpoint(), which mints a fresh MessagePort pair over the same worker. Each consumer gets its own proxy and its own message ordering, which keeps an unrelated slow call from stalling a fast one behind it in a single port’s queue.
// main.ts — one worker, one endpoint per feature area
const base = Comlink.wrap<SheetApi>(worker);
const forGrid = Comlink.wrap<SheetApi>(await base[Comlink.createEndpoint]());
const forChart = Comlink.wrap<SheetApi>(await base[Comlink.createEndpoint]());
// Independent queues: a 400 ms histogram on `forChart` no longer delays
// the grid's next page fetch on `forGrid`.
const [page, bins] = await Promise.all([forGrid.page(0, 100), forChart.histogram('price', 64)]);
// Endpoints are proxies too — release them with the feature that owns them.
forChart[Comlink.releaseProxy]();
This is ordering isolation, not parallelism: the worker still has one thread, and a 400 ms histogram still occupies it for 400 ms. For real concurrency, put the proxy in front of a pool rather than a single worker, sizing it by the heuristics in Worker Pool Management — each pool member exposes the identical API, and the pool’s dispatcher picks an idle one per call.