Passing Callbacks and Transferables Through Comlink
Two argument types break the illusion that a proxied method is an ordinary function: a callback, which cannot be cloned at all, and a megabyte buffer, which can be cloned and should not be.
Both have one-word fixes — Comlink.proxy and Comlink.transfer — and both have lifecycle consequences that a one-word fix hides. This page is the mechanics half of Comlink & RPC Patterns, under Web Workers Architecture & Communication, and it covers what those two wrappers actually allocate, when to release them, and how to tell from a symptom which one you got wrong.
A Complete Example With Both
A worker that decodes an image, reports progress, and hands the pixels back without copying them:
// decode-worker.ts
import * as Comlink from 'comlink';
const api = {
async decode(
compressed: Uint8Array,
onProgress: (fraction: number) => void, // arrives as a proxy
): Promise<Uint8ClampedArray> {
const pixels = new Uint8ClampedArray(compressed.length * 8);
const rows = 1024;
for (let y = 0; y < rows; y++) {
decodeRow(compressed, pixels, y);
// A hop per call: report on a stride, never per row.
if (y % 64 === 0) void onProgress(y / rows);
}
// Move the pixels out instead of cloning them on the way back.
return Comlink.transfer(pixels, [pixels.buffer]);
},
};
export type DecodeApi = typeof api;
Comlink.expose(api);
// page.ts
import * as Comlink from 'comlink';
import type { DecodeApi } from './decode-worker';
const api = Comlink.wrap<DecodeApi>(
new Worker(new URL('./decode-worker.ts', import.meta.url), { type: 'module' }));
export async function decode(compressed: Uint8Array, bar: HTMLProgressElement) {
const onProgress = Comlink.proxy((fraction: number) => { bar.value = fraction; });
try {
// `compressed` moves out; `pixels` moves back. Neither is copied.
const pixels = await api.decode(
Comlink.transfer(compressed, [compressed.buffer]), onProgress);
console.assert(compressed.byteLength === 0, 'argument should be detached');
return pixels;
} finally {
onProgress[Comlink.releaseProxy](); // closes the port pair
}
}
Four wrappers appear in ten lines of call site, and each one corresponds to a concrete allocation or a concrete byte movement. The rest of this page is what those are.
What Comlink.proxy Allocates
Comlink.proxy(fn) does not send your function anywhere — it cannot, because functions do not clone. It creates a MessageChannel, keeps port1 on the calling side with fn bound to its message handler, and marks port2 as the value to send. Comlink’s default proxy transfer handler puts that port in the postMessage transfer list, so the worker receives a live port and wraps it in a proxy of its own.
The consequences follow directly:
- Invoking the callback in the worker is a message, not a call. It returns a promise. Awaiting it inside a tight loop serialises the loop against the page’s event loop — usually a 10–100× slowdown.
void-ing the call, as in the example, keeps the loop running and treats the update as fire-and-forget. - Arguments to the callback are structured-cloned like any other message. A callback that receives a 4 MB intermediate result copies 4 MB per invocation. Send scalars — a fraction, a row index, a count.
- The port keeps both sides alive. A
MessagePortwith a registeredonmessageis a GC root on the page and in the worker. That is why an unreleased callback proxy is a genuine leak rather than an untidy loose end. - Errors thrown inside your callback surface on the page, not in the worker. The worker’s invocation resolves regardless. A callback that throws every time therefore produces a stream of page-side unhandled rejections and no visible worker failure.
At 0.1–0.5 ms per hop, 400,000 progress calls cost 40–200 s of queue time — far longer than the decode. Twenty to a hundred updates over the life of a job is smooth at 60 fps and costs under 50 ms in total. Pick the stride from the job size (Math.max(1, Math.floor(total / 50))), not from a fixed row count that behaves differently on a 1,000-row file and a 10-million-row one.
What Comlink.transfer Actually Does
Comlink.transfer(value, transferList) attaches the transfer list to the value using a WeakMap that Comlink consults while building the outgoing message; the list is then handed to postMessage as its second argument. That is the entire mechanism — the semantics are the platform’s, described in Transferable Objects & Zero-Copy, not the library’s.
Three practical rules fall out of that:
Transfer the buffer, pass the view. Comlink.transfer(view, [view.buffer]) is the correct incantation for a typed array: the view is what the method signature expects, and the buffer is what the platform can move. Passing [view] in the list throws DataCloneError because a Uint8Array is not itself transferable.
A transferred value is detached, not gone. The local variable still exists; its byteLength is 0 and every read yields undefined. Code that transfers a buffer and then reads it produces silent garbage rather than an exception, which is why the console.assert in the example earns its place.
Returning bytes works identically. return Comlink.transfer(result, [result.buffer]) moves the reply out of the worker. Without it, a 32 MB pixel buffer is structure-cloned on the way home — 40–60 ms of copying plus 32 MB of fresh allocation on the page heap.
Releasing What You Allocated
Transferred buffers clean themselves up: ownership moved, and the old view is inert. Proxies do not. Every Comlink.proxy(fn), every remote instance from new, and every endpoint from createEndpoint() holds a port pair open until released.
// A component that creates one proxy per job and releases it on unmount.
let active: (ProxyMarked & ((f: number) => void)) | null = null;
function start(job: Job) {
stop(); // never overlap two live proxies
active = Comlink.proxy((f: number) => setProgress(f));
void api.run(job, active).finally(stop);
}
function stop() {
active?.[Comlink.releaseProxy]();
active = null;
}
The symptom of getting this wrong is distinctive and worth memorising: the worker’s heap grows in even steps, one step per user action, and every retained object is reachable from a MessagePort. A heap snapshot shows a growing set of ports with no obvious owner, because the owner is Comlink’s internal table. The diagnosis procedure is the general one in Heap Snapshot Diffing for Worker Leaks — take a snapshot, perform the action ten times, snapshot again, and look at what the delta retains.
When a Raw Port Beats a Callback Proxy
A callback proxy is a MessageChannel with ergonomics on top. Once a job needs more than progress — cancellation, two-way chatter, ordered chunks — those ergonomics start costing more than they give, and handing the worker a plain port is both smaller and clearer.
// page.ts — one port, two directions, no proxy bookkeeping
const { port1, port2 } = new MessageChannel();
port1.onmessage = (event) => {
const update = event.data as { done: number; total: number } | { chunk: Float32Array };
if ('chunk' in update) append(update.chunk); else setProgress(update.done / update.total);
};
// The port is transferable, so it goes in the transfer list like any other cargo.
await api.stream(jobSpec, Comlink.transfer(port2, [port2]));
// Cancellation travels the same channel, in the other direction.
cancelButton.onclick = () => port1.postMessage({ cancel: true });
The worker receives an ordinary MessagePort, posts as many messages as it likes, and listens for control messages on the same channel. Nothing needs releasing beyond port1.close(), chunk payloads can carry their own transfer lists, and the traffic reads plainly in a DevTools message log because it is your own shape rather than an RPC envelope.
The trade is that you are back to writing a small protocol: message shapes, an end-of-stream marker, and a decision about what happens if the page stops reading. That last one is the important one — a worker that posts faster than the page drains builds an unbounded queue in the receiving thread, the failure mode covered in Implementing Backpressure with Message Credits.
A workable division of labour: use Comlink.proxy when the callback is genuinely fire-and-forget and bounded in volume — progress bars, log lines, occasional status — and hand over a port when the callback is really a stream in disguise. Both can appear in the same API, and both can be arguments to the same method.
Gotchas
Wrapping the wrong thing. Comlink.proxy(someObject) is legal and rarely what you want: it sends the whole object as a proxy, so every property read from the worker becomes a hop back to the page. Wrap the function, not its container.
Transferring state the application still owns. A buffer held in a store, a cache, or a React ref must never be transferred; the next reader sees byteLength === 0. Transfer values constructed for the call — a copy you made deliberately, or a buffer the worker just produced.
A proxy captured in a closure outlives the job. Comlink.proxy(() => setState(...)) inside a component captures that component’s scope. Released, it goes away; unreleased, it pins the component’s entire closure in both threads long after unmount.
releaseProxy is a symbol, not a method name. session.releaseProxy() compiles under a loose type and does nothing at runtime except make one more round trip looking for a method that does not exist. It is always proxy[Comlink.releaseProxy]().
Performance Note
A rule of thumb for budgeting a job that uses both wrappers: one transfer per call, fifty callbacks per job. Transfers are effectively free and unbounded in size, so move everything large. Callback invocations cost a hop each, so treat them as a budget of roughly 50 for the whole job regardless of how many iterations the loop runs — that is under 25 ms of hops at the pessimistic end of the range, and it renders as a smooth progress bar at any job length.