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 crosses the boundary for one decode call A single call carries three distinct things. The compressed bytes move by transfer, so the page's view is detached and the worker owns the memory. The progress callback stays on the page; Comlink allocates a message channel and sends one port, so the worker holds a stand-in that posts back through that port each time it is invoked. The decoded pixels move by transfer on the return path, detaching the worker's view. Below, a note records that the callback port stays open for the lifetime of the proxy and must be released explicitly, whereas transferred buffers need no cleanup because ownership moved. One call, three kinds of cargo Page Worker compressed bytes — transferred page view detaches, byteLength 0 MessageChannel for the callback function stays home; a port travels decoded pixels — transferred back worker view detaches instead Transferred buffers need no cleanup — ownership moved. The callback's port pair stays open until releaseProxy is called, which is the only leak this call can produce.
Two of the three cargoes are self-cleaning. The middle one is a live channel, and it is the one that shows up in a heap snapshot a week later.

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 MessagePort with a registered onmessage is 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.
Choose the stride, not the callback

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.

Cost of returning a 32 megabyte pixel buffer, cloned versus transferred Two bars measured in milliseconds. Returning the buffer without a transfer list clones it: about forty-eight milliseconds of copying inside the postMessage call plus a fresh thirty-two megabyte allocation on the page heap, and the worker's own copy stays alive until garbage collection. Returning it wrapped in Comlink.transfer costs about zero point zero four milliseconds regardless of size, allocates nothing new, and detaches the worker's view immediately. Returning 32 MB of pixels: clone versus transfer cloned reply ≈ 48 ms of copying, on both event loops 48 ms + 32 MB allocated on the page heap, and the worker's copy lives until the next collection transferred reply ≈ 0.04 ms — a pointer move, at any size nothing allocated; the worker's view detaches the moment the reply is posted 0 18 36 54 ms The clone is paid twice over: once in time, once in peak memory. Transfer is O(1) in both.
The forty-eight milliseconds is three full frames of jank on the page, which is why a decode that "works" in development often stutters on a real image.

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.

Worker heap over ten jobs, with and without releasing the callback proxy A step chart over ten jobs. Without a release call the heap climbs one step per job, from about four megabytes to about fourteen, and never falls, because each job leaves a message port pair and its captured closure alive. With releaseProxy in a finally block the heap returns to its baseline of about four megabytes after every job, producing a flat sawtooth instead of a staircase. Worker heap across ten jobs no release — a port pair per job released in finally — flat 4 MB 9 MB 14 MB job 1 job 5 job 10 The staircase is the signature: equal steps, one per action, never reclaimed by a collection.
Nothing about the pumpkin line looks like a leak in a single session — it looks like a worker warming up. Ten jobs is enough to tell them apart.

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.

Frequently Asked Questions

Why do I get "could not be cloned" when passing an arrow function to a worker method?
Functions are not structured-cloneable, so the argument is rejected before it ever reaches the worker. Wrap it: api.run(Comlink.proxy(onProgress)). Comlink then allocates a MessageChannel, keeps your function on the calling side, and sends one port across, so the worker gets an invocable stand-in rather than a copy.
Does Comlink.transfer copy the buffer if I forget the transfer list?
Yes — silently. Comlink.transfer(view, []) and a bare view both fall back to structured clone, which copies every byte and leaves the original intact. The failure is a performance regression rather than an error, so assert on view.byteLength === 0 after the call in development: a detached buffer proves the transfer actually happened.

See also