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.

The same call written by hand and written as a remote procedure call On the left, the hand-written path has five layers the developer owns: a request type union, a correlation identifier allocator, a pending promise map, a switch statement in the worker, and an error stringifier. On the right, the remote procedure call path has one layer the developer owns — the API object — and Comlink generates the envelope, the correlation, the promise settlement and the error rethrow beneath it. What you own, and what the transport owns Hand-written postMessage Comlink proxy request type union — kept in sync by hand correlation ids + pending promise map switch on message kind, both directions error to string, and back to an Error the parser — the only domain code envelope, correlation, settlement and error rethrow — generated by the proxy 1.1 kB, zero project code the API type — one exported interface checked on both sides at compile time the parser — the only domain code RPC does not remove the message. It removes the four layers that were never about parsing.
The parser block is identical in both columns. Everything above it on the left is transport plumbing that a proxy generates for free — and, critically, keeps in sync with the types.

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.

Expose synchronously or lose the first call

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.

One proxied call, from property access to resolved promise A sequence across two lanes. On the page lane, accessing api.parse records the property path and returns a proxy without sending anything; invoking it posts an APPLY message carrying the path, the argument list and a message identifier, and returns a pending promise. On the worker lane, the exposed listener resolves the path against the API object, applies the function, awaits the result and posts it back with the same identifier. Back on the page lane, the matching reply settles the promise with a structured clone of the return value, and later property reads on that value are local and free. await api.parse(csv) — what actually crosses the thread boundary Page Worker api.parse — records path ['parse'], sends nothing invoke — postMessage { APPLY, path, args, id } promise pending — the event loop keeps turning resolve path on the API object, apply, await result the only code that is yours reply { id, value } — clone settles the promise 0.1–0.5 ms each way The returned value is a plain clone, not a proxy — reading its fields afterwards never touches the worker.
Only the invoke arrow and the reply arrow cost anything. That is the unit of RPC budgeting: one await, one round trip.
Performance

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(),
]);
Wall-clock cost of four calls issued serially, pipelined, and merged into one method Three timelines against a millisecond scale. Serial awaits cost four full round trips in sequence, about 1.2 milliseconds, with the worker idle between each. Promise.all issues all four immediately so the hops overlap and the total is about 0.45 milliseconds. A single coarse method that returns all four values costs one round trip, about 0.3 milliseconds, and the worker does the same work in one go. Four values from the worker, three ways serial awaits ≈ 1.2 ms Promise.all ≈ 0.45 ms — hops overlap one coarse call ≈ 0.3 ms — and one clone, not four 0 0.4 0.8 1.2 ms Latency, not bandwidth: all three move the same handful of bytes.
Pipelining rescues an accidental sequence; a coarse method removes the sequence. Prefer the third bar and keep the second in reserve for genuinely dynamic call sets.

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.

A callback proxy is a live port, not a value

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.

Shared memory still needs isolation headers

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.

Frequently Asked Questions

Does Comlink make worker calls slower than raw postMessage?
Per call, the difference is small: Comlink adds one property-path array and a Reflect.apply on the worker side, which measures at 0.02–0.05 ms on top of the 0.1–0.5 ms postMessage round trip. What makes RPC code slower is chattiness — a proxy makes await api.config.retries look free when it is a full round trip, so a loop that reads five properties per item costs five hops per item. Batch the call surface, not the transport.
Why does my Comlink call hang forever with no error?
Almost always a missing Comlink.expose() on the worker side, or an expose that ran after the first call arrived. Comlink resolves a call only when the matching reply message comes back, and a worker that never registered a listener never replies, so the promise stays pending — there is no timeout. Confirm the worker script actually executed (console.log at module top level), then confirm expose runs synchronously at the top level, not inside an async bootstrap.
How do I send an ArrayBuffer through Comlink without copying it?
Wrap the argument in Comlink.transfer(buffer, [buffer]) — the second argument is the transfer list Comlink hands to postMessage. The buffer is neutered on the sending side exactly as it would be with a raw transfer, so treat the local reference as dead afterwards. The mechanics are identical to Transferable Objects & Zero-Copy; Comlink only forwards the list.
Do I need to release proxies, and what happens if I do not?
Yes for any proxy you create dynamically — remote class instances, Comlink.proxy() callbacks, and endpoints from createEndpoint(). Each one pins an entry in the worker’s proxy table, so a component that wraps a fresh instance per mount and never calls proxy[Comlink.releaseProxy]() grows the worker heap monotonically. That is the RPC-shaped version of the leak pattern catalogued in Identifying Memory Leaks in Workers.

See also