Using MessageChannel for Worker-to-Worker Links

Workers cannot see each other. A decoder that feeds a renderer has to route every frame through the page by default — two hops, two structured clones or transfers, and a main thread woken up for data it does not use.

A MessagePort fixes that: the page creates a channel, hands one end to each worker, and steps out of the path. This is a topology technique from Message Passing Strategies, part of Web Workers Architecture & Communication.


Wiring Two Workers Together

// page.ts — the page's only job is introduction
const decoder = new Worker(new URL('./decoder.worker.ts', import.meta.url), { type: 'module' });
const renderer = new Worker(new URL('./renderer.worker.ts', import.meta.url), { type: 'module' });

const { port1, port2 } = new MessageChannel();

// Ports are transferable — each end must be in the transfer list of its own message.
decoder.postMessage({ kind: 'LINK', role: 'producer', port: port1 }, [port1]);
renderer.postMessage({ kind: 'LINK', role: 'consumer', port: port2 }, [port2]);
// decoder.worker.ts
let out: MessagePort | null = null;

self.addEventListener('message', (event: MessageEvent) => {
  if (event.data.kind !== 'LINK') return;
  out = event.data.port as MessagePort;
  out.start();                       // required when using addEventListener
});

function emitFrame(frame: Uint8ClampedArray): void {
  // Straight to the renderer: one hop, and the page never wakes.
  out?.postMessage({ kind: 'FRAME', frame }, [frame.buffer]);
}
// renderer.worker.ts
self.addEventListener('message', (event: MessageEvent) => {
  if (event.data.kind !== 'LINK') return;
  const inbound = event.data.port as MessagePort;
  inbound.onmessage = (message) => draw(message.data.frame);   // implicitly starts the port
  // No inbound.start() needed here — assigning onmessage starts it.
});

The saving is not subtle at high message rates. Relaying through the page costs two deliveries and two copies (or two transfers) per item, and — more importantly — a main-thread task per item, which competes with rendering. A direct link costs one delivery and leaves the main thread asleep.

Relaying through the page versus a direct port between workers Two topologies for the same decoder-to-renderer pipeline. In the relayed topology, each frame travels from the decoder to the page and then from the page to the renderer: two deliveries, two transfers, and a main-thread task per frame that competes with rendering. In the direct topology, the page creates a message channel and gives one port to each worker, after which frames travel decoder to renderer in a single delivery and the main thread is never woken. A note records that at sixty frames per second the relayed version schedules sixty main-thread tasks per second purely as a courier. Who carries each frame relayed through the page decoder page a task per frame renderer 2 deliveries · 2 transfers · 1 main-thread task direct port decoder page introduces once, then idle renderer 1 delivery · 1 transfer · no main-thread task At 60 fps the left-hand topology schedules 60 main-thread tasks per second purely as a courier.
The dashed arrows on the right happen once, at setup. Everything after that bypasses the page entirely.

The start() Rule

A port received from a transfer is queuing but not dispatching. Two ways to start it, and the asymmetry between them is the single most common source of a channel that appears dead:

port.onmessage = handler;          // implicitly calls start()
port.addEventListener('message', handler);
port.start();                      // REQUIRED — without this, nothing is dispatched

Nothing warns about the missing start(). Messages accumulate in the port’s queue, the sender sees no error because postMessage never reports delivery, and the symptom is a pipeline that silently produces nothing. When a channel does not work, this is the first thing to check.

The queuing behaviour is also useful. Because messages sent before start() are held rather than dropped, a worker can receive its port, do asynchronous setup — load a model, compile WebAssembly — and call start() when it is genuinely ready, without the producer needing to know or wait.


High message rates. Above roughly 100 messages per second, the main thread’s share of the relay becomes a visible cost — 60 fps of frames is 60 tasks per second doing nothing but forwarding.

Large transfers. Relaying a transferable through the page transfers it twice. Once is free; twice is still nearly free, but the intermediate hop means the page briefly owns a buffer it has no use for, and any bug that reads it there detaches or corrupts the pipeline.

Pipelines with several stages. Decode → transform → render as three workers chained by two ports keeps every stage’s output on the thread that consumes it, and lets each stage apply its own backpressure — the pattern described in Streaming & Backpressure Across Threads.

Isolation between subsystems. One port per feature area means a slow message in one does not delay another, since each port has its own queue.

Where it does not pay: occasional request/response traffic, anything the page needs to observe anyway, and any case where a SharedArrayBuffer would serve better because both sides need the same bytes rather than a handoff.

Main-thread cost of relaying versus a direct link at three message rates Three pairs of bars measuring main-thread time consumed per second purely by relaying. At ten messages per second, relaying costs about three milliseconds per second and a direct link costs nothing — a difference nobody notices. At one hundred messages per second, relaying costs about thirty milliseconds per second. At one thousand messages per second, relaying costs about three hundred milliseconds per second, or thirty percent of the main thread, while the direct link still costs nothing. Main-thread time spent forwarding, per second of pipeline 10 msg/s ≈ 3 ms — invisible 100 msg/s ≈ 30 ms — two dropped frames per second 1000 msg/s ≈ 300 ms — 30% of the main thread, doing nothing but forwarding 0 150 ms 300 ms The direct-link bar is zero at every rate — which is why the decision is about message rate alone.
Below about 100 messages per second the relay is free in practice; above it, the page is paying a tax to be a courier.

Lifetime and Teardown

Ports keep things alive. A started MessagePort with a live onmessage handler is a GC root on both sides, so a pipeline that is rebuilt per document, per route or per job must close its ports explicitly:

function teardown(port: MessagePort): void {
  port.onmessage = null;
  port.close();          // idempotent; the other end simply stops receiving
}

Closing one end does not notify the other — there is no close event on a MessagePort. If the consumer needs to know the producer is finished, that has to be an explicit message ({ kind: 'END' }) before the close. A pipeline that relies on port closure as an end-of-stream signal will wait forever.

Terminating a worker closes its ports implicitly, which is convenient and also a trap: the other worker keeps its end open, keeps its handler registered, and waits. A supervisor that restarts a crashed worker must rebuild the channel and re-link both ends, since the surviving port is attached to a thread that no longer exists.


Keeping the Page in the Loop Without Putting It in the Path

The objection to a direct link is usually observability: if frames no longer pass through the page, the page cannot count them, show progress, or notice a stall. Three patterns recover that without reintroducing the relay.

A separate status channel. Create a second MessageChannel between each worker and the page, used only for low-rate telemetry — counts, errors, heartbeats. The data path stays direct; the status path carries perhaps one message per second, which costs nothing.

Periodic summaries rather than per-item events. The producer accumulates counters and posts them on an interval. A stall is then visible as a summary that stops changing, which is a more reliable signal than a missing item and generates a fraction of the traffic.

A development-only tee. Behind a flag, have the producer post a copy of each message to the page as well. Useful when debugging a pipeline whose contents you cannot otherwise inspect, and disabled by default because it restores exactly the cost the direct link removed.

// decoder.worker.ts — status is a different channel, and a different rate
let frames = 0;
setInterval(() => status?.postMessage({ kind: 'STATS', frames, at: performance.now() }), 1_000);

The general principle is worth stating: the page should be able to observe a pipeline it is not part of. Building that observation in from the start is much easier than adding it later, when the only tool available is putting the page back in the data path to see what is happening.


Setting up a direct link, in the order the steps must happen Four steps in a required order. The page creates a channel and holds both ports. Each port is transferred to its worker in the transfer list of its own message. Each worker starts its port, either explicitly or implicitly by assigning onmessage. Only then does the producer begin sending, ideally after a ready message from the consumer. The order that makes a link work page creates the channel it owns the topology and the teardown transfer one port each in its own transfer list ports are not cloneable each worker starts its port onmessage starts it addEventListener does not then the producer sends after a READY message queued until started Messages sent before start() are queued, not dropped — which is why setup can be asynchronous. Each step is cheap; skipping one is what makes the next expensive.
The third column is the one that fails silently: a port that is never started queues forever.

Gotchas

Forgetting the transfer list. postMessage({ port }) without [port] throws DataCloneError: ports are transferable, never cloneable.

Creating the channel in a worker instead of the page. It works — a worker can create a MessageChannel and send a port onward — but then no single place knows the topology, and teardown has no owner. Let the page mint channels and hand out ports, even when it is not part of the data path.

Reusing a transferred port on the sending side. Once transferred, the port is neutered locally. Keep the other end if the page needs to participate — or create a second channel.

Building a mesh instead of a pipeline. Ports are cheap enough that it is tempting to link every worker to every other, and the result is a topology nobody can reason about: no single place shows the data flow, and a stall in one link is invisible from the others. Prefer a chain or a star with an explicit owner, and keep the number of links small enough to draw on a whiteboard.

Relinking without closing the old ports. Rebuilding a pipeline leaves the previous pair open on both sides, each with a live handler pinning its closures.

Assuming ordering across two channels. Messages are ordered within a port, not between ports. A pipeline that sends control on one channel and data on another has no guarantee about their relative arrival.

Linking before both workers are ready. The producer may receive its port and start sending while the consumer is still evaluating its module. Messages queue safely, so nothing is lost — but if the consumer never calls start() because its setup threw, the producer fills a queue nobody drains. A READY message from the consumer before the producer begins is one line and removes the ambiguity.

Debugging is thinner here. DevTools shows worker-to-worker traffic less readily than page-to-worker traffic. A ?debug mode that relays a copy of each message to the page, enabled only in development, restores visibility — see Chrome DevTools Worker Debugging.


Performance Note

A decode-to-render pipeline at 60 fps with 1.8 MB frames, measured on a 2023 laptop: relaying through the page cost 0.42 ms of main-thread time per frame — 25 ms per second, or roughly one and a half frames’ worth of budget spent purely forwarding. The direct port version cost 0 ms on the main thread and reduced end-to-end frame latency from 1.1 ms to 0.5 ms, because one delivery replaced two.

Frequently Asked Questions

Why does my port never receive anything?
You almost certainly used addEventListener('message', …) without calling port.start(). A MessagePort begins queuing messages immediately but does not dispatch them until it is explicitly started — and assigning port.onmessage = … starts it implicitly, which is why the onmessage form appears to work while the listener form does not.
Can two workers share memory instead of exchanging messages?
Yes, and it is the better answer for continuous high-rate data: allocate a SharedArrayBuffer on the page, post it to both workers, and coordinate with Atomics. A direct port is the right tool for discrete messages and transferred buffers; shared memory is the right tool when the two threads need concurrent access to the same bytes at sub-millisecond latency.

See also