Message Passing Strategies

postMessage takes two arguments, which makes it look like a solved problem. It is not — it is the transport layer of a distributed system whose two nodes happen to share a process. Everything that makes distributed systems awkward applies at the boundary between a page and its workers: no delivery acknowledgement, no ordering guarantee across separate channels, no backpressure signal, and no shared failure domain. None of it is handled for you. This guide is part of the Web Workers Architecture & Communication reference, and it covers the protocol layer you have to build on top of postMessage before a worker is safe to put in front of users.

The arc below is the one every production messaging layer converges on: type the envelope, correlate replies to requests, isolate independent streams onto their own ports, add flow control so a fast producer cannot bury a slow consumer, and coalesce bursts so message rate is bounded by the display refresh rate rather than by whatever the data source feels like emitting. That final step has the most immediate payoff and is worth seeing up front — an unbounded event source collapsed into a single postMessage per animation frame.

Frame-aligned coalescing: an unbounded event source collapsed to one postMessage per animation frame Three consecutive animation frames. Seven, five and nine source events arrive inside each 16.7 ms frame and are written into a coalescer buffer keyed by entity id, where superseded values are overwritten. At each requestAnimationFrame tick the buffer flushes as a single postMessage carrying three, four and three surviving keys, so twenty-one source events become three cross-thread messages and the message rate follows the display refresh rate rather than the feed. One requestAnimationFrame tick per frame — 16.7 ms at 60 Hz source events coalescer buffer one postMessage 7 events buffered 3 keys after last-wins 5 events buffered 4 keys after last-wins 9 events buffered 3 keys after last-wins postMessage(batch) postMessage(batch) postMessage(batch) frame 1 frame 2 frame 3 21 source events → 3 messages: the message rate follows the display, not the feed
Coalescing into a single postMessage per requestAnimationFrame tick pins the cross-thread message rate at the refresh rate. The feed's burst size stops mattering — only the number of distinct entities in a frame does.

The Failure This Guide Prevents

A fleet-tracking dashboard subscribes to a WebSocket that emits vehicle position updates. Each update is a small object — id, latitude, longitude, heading, timestamp. Projection maths and viewport culling run in a worker, so the main thread only draws. In development, with a demo feed of 20 vehicles at 1 Hz, everything is smooth. In production, with 4,000 vehicles and a broker that batches at up to 1,800 updates per second, the page degrades in a very specific way:

  • Interaction latency climbs steadily over the first minute and never recovers, even when the feed goes quiet.
  • The worker’s own compute time per update stays at roughly 0.2 ms — the worker is not slow.
  • Memory grows monotonically; a heap snapshot shows tens of thousands of live message objects retained by the internal task queue.
  • Navigating away and back leaves the old worker running, still projecting positions nobody is looking at.
  • One request that timed out logs a rejection thirty seconds later, when its reply finally arrives and resolves nothing.

Every one of those symptoms is a protocol defect, not a performance defect. The producer emits faster than the consumer drains and nothing tells it to stop, so the queue becomes an unbounded buffer. Replies are matched by arrival order rather than by identity, so a slow reply corrupts a fast one. Features share a single handler, so teardown is all-or-nothing and nothing gets torn down. Fixing this by making the worker faster is chasing the wrong variable: the worker was never the bottleneck. Where serialization genuinely is the dominant cost, postMessage Bottleneck Analysis covers the measurement side; this guide covers the shape of the protocol.

The same worker behind an ad-hoc protocol and behind a designed one Two stacks side by side. The ad-hoc stack: one shared onmessage handler for every feature, a queue that is the only buffer with no depth API and no backpressure signal, replies matched by arrival order so a slow reply resolves the wrong promise, ending in latency that climbs and a heap that grows. The designed stack: a typed envelope carrying id, kind and payload, one MessagePort per feature with independent routing and close, a pending Map keyed by id and armed with a timeout so a late reply finds no entry and is dropped, and a credit window that caps the number of in-flight messages. Same worker, two protocols Ad-hoc — one shared handler worker.onmessage — one handler for all the queue is the only buffer no depth API, no backpressure signal replies matched by arrival order a slow reply resolves the wrong promise latency climbs, heap grows, nothing recovers Designed — typed and bounded typed envelope { id, kind, payload } one MessagePort per feature independent routing and close() pending Map keyed by id, with a timeout a late reply finds no entry and is dropped credit window caps in-flight work at N
Every symptom in the fleet-dashboard incident maps to a missing row on the left. Nothing on the right makes the worker faster — it makes the boundary observable, bounded and independently tearable-down.

Prerequisites

Before implementing any of the steps below, make sure the following are in place:

  • Module workers. Create workers with new Worker(url, { type: 'module' }) so both threads can import the same protocol definitions and stack frames keep real function names. Bundler specifics are covered in Bundling Module Workers with Vite and Webpack.
  • A shared TypeScript module that neither thread can bypass — the envelope types live there and nowhere else.
  • structuredClone() available (Chrome 98+, Firefox 94+, Safari 15.4+) so you can exercise the clone path on one thread while testing.
  • messageerror handlers registered on both sides. Deserialization failures do not fire message, and a codebase that only registers onmessage will lose those messages silently.
  • Worker tracks visible in the Performance panel, so you can confirm which thread is actually busy. Chrome DevTools Worker Debugging covers attaching to the worker isolate.
  • A load generator that exceeds your expected peak by 3–5×. Flow-control bugs are invisible below saturation; every one of the symptoms above only appears when the producer outruns the consumer.
  • crossOriginIsolated === true — only if you intend to evaluate shared memory as a transport. Without COOP/COEP, SharedArrayBuffer does not exist.

Step 1 — Define the Message Envelope as a Shared Discriminated Type

Most cross-thread bugs are shape bugs: the sender adds a field, the receiver still switches on the old one, and the mismatch surfaces as a silent no-op because onmessage handlers rarely have a default case that throws. A single shared module of discriminated union types moves that class of bug to compile time and gives you exhaustiveness checking on the receiving switch.

// protocol.ts — imported by BOTH the main thread and the worker
export interface Request<K extends string, P> {
  readonly id: number;        // monotonic per sending thread; correlates the reply
  readonly kind: K;
  readonly payload: P;
}

export interface Ok<K extends string, R> {
  readonly id: number;
  readonly kind: K;
  readonly ok: true;
  readonly result: R;
}

export interface Fail {
  readonly id: number;
  readonly ok: false;
  // Errors do not survive a thread hop with a usable prototype — send fields.
  readonly error: { name: string; message: string; stack?: string };
}

export type ProjectRequest = Request<'project', { ids: Int32Array; lonLat: Float64Array }>;
export type CancelRequest = Request<'cancel', { targetId: number }>;
export type AnyRequest = ProjectRequest | CancelRequest;

export type ProjectOk = Ok<'project', { ids: Int32Array; xy: Float32Array }>;
export type AnyResponse = ProjectOk | Fail;

// Exhaustiveness guard: adding a request kind without handling it fails the build.
export function assertNever(x: never): never {
  throw new Error(`Unhandled message kind: ${JSON.stringify(x)}`);
}

The worker’s dispatcher then has no room to drift:

// projection.worker.ts
import { type AnyRequest, type AnyResponse, assertNever } from './protocol';

self.onmessage = (event: MessageEvent<AnyRequest>): void => {
  const msg = event.data;
  switch (msg.kind) {
    case 'project': {
      const xy = project(msg.payload.lonLat);           // Float32Array
      const reply: AnyResponse = {
        id: msg.id, kind: 'project', ok: true,
        result: { ids: msg.payload.ids, xy },
      };
      // Transfer the result buffers: they are freshly allocated and the worker is done with them.
      self.postMessage(reply, [xy.buffer, msg.payload.ids.buffer]);
      return;
    }
    case 'cancel':
      cancelled.add(msg.payload.targetId);
      return;
    default:
      return assertNever(msg);
  }
};
Trade-off: typing costs nothing at runtime, but it is not validation

Types are erased before the code runs, so a message arriving from a stale worker build, a browser extension or another origin still reaches your handler with whatever shape it likes. Types protect you from your own mistakes; a cheap runtime guard on typeof msg.kind === 'string' plus a default case that logs and drops protects you from everyone else's. Do not reach for a schema validator here — running one on every message in a 60 Hz stream costs more than the handler it protects.

Anatomy of one postMessage call: the cloned envelope beside the transferred buffer list A single postMessage call is split into two halves. The envelope, structured-cloned in full, holds four fields: id, which correlates the reply; kind, which the receiving switch discriminates on; payload, cloned field by field; and result, carrying the typed arrays. The transfer list beside it names xy.buffer and ids.buffer, whose ownership moves in constant time and whose byteLength on the sender drops to zero. Both halves travel in the same call: the listed buffers move while everything else in the envelope is copied. self.postMessage(reply, [xy.buffer, ids.buffer]) the envelope — cloned in full id: 7 correlates the reply kind: 'project' the switch discriminant ok: true success or Fail, never both result: { ids, xy } views onto the buffers the transfer list — moved xy.buffer ownership moves, O(1) in size ids.buffer sender's view detaches to 0 bytes one call, two mechanisms: listed buffers move, the rest is copied Types are erased before any of this runs — the receiver still needs a default case that logs and drops.
One call, two mechanisms. The envelope is small and cloned so both threads keep a copy; the buffers named in the transfer list move, which is why a rename or a nesting change silently downgrades the send to a copy.

Step 2 — Correlate Requests and Responses with IDs and Timeouts

Raw postMessage is fire-and-forget: nothing links a reply to the call that caused it. Codebases that guess — resolving the oldest pending promise on every message — work until two requests are in flight at once, at which point results silently swap. The fix is a correlation ID and a pending map, plus two things people usually forget: a timeout that cleans up after itself, and a worker.onerror handler that fails everything outstanding when the worker dies.

// worker-client.ts
import type { AnyRequest, AnyResponse } from './protocol';

interface Pending {
  resolve: (value: unknown) => void;
  reject: (reason: Error) => void;
  timer: ReturnType<typeof setTimeout>;
  startedAt: number;
}

export class WorkerClient {
  readonly #worker: Worker;
  readonly #pending = new Map<number, Pending>();
  #nextId = 1;

  constructor(url: URL) {
    this.#worker = new Worker(url, { type: 'module' });
    this.#worker.addEventListener('message', this.#onMessage);
    // A worker that throws at top level never replies to anything in flight.
    this.#worker.addEventListener('error', (e) => this.#failAll(new Error(`Worker error: ${e.message}`)));
    this.#worker.addEventListener('messageerror', () => this.#failAll(new Error('MessageDeserializationFailed')));
  }

  request<T>(msg: Omit<AnyRequest, 'id'>, transfer: Transferable[] = [], timeoutMs = 5_000): Promise<T> {
    const id = this.#nextId++;
    return new Promise<T>((resolve, reject) => {
      const timer = setTimeout(() => {
        this.#pending.delete(id);
        // The worker keeps computing — see the cancellation note below.
        reject(new Error(`Request ${id} (${msg.kind}) timed out after ${timeoutMs} ms`));
      }, timeoutMs);

      this.#pending.set(id, { resolve: resolve as Pending['resolve'], reject, timer, startedAt: performance.now() });
      this.#worker.postMessage({ ...msg, id }, transfer);
    });
  }

  get inFlight(): number {
    return this.#pending.size;   // the only queue-depth signal you get
  }

  #onMessage = (event: MessageEvent<AnyResponse>): void => {
    const msg = event.data;
    const entry = this.#pending.get(msg.id);
    if (!entry) return;          // late reply to a timed-out or cancelled request — drop it
    clearTimeout(entry.timer);
    this.#pending.delete(msg.id);
    if (msg.ok) entry.resolve(msg.result);
    else entry.reject(Object.assign(new Error(msg.error.message), { name: msg.error.name, stack: msg.error.stack }));
  };

  #failAll(reason: Error): void {
    for (const { reject, timer } of this.#pending.values()) { clearTimeout(timer); reject(reason); }
    this.#pending.clear();
  }

  dispose(): void {
    this.#failAll(new Error('Worker disposed'));
    this.#worker.terminate();
  }
}

Two details carry most of the value. The if (!entry) return line is what makes a late reply harmless instead of a crash — without it, a reply arriving after a timeout hits a resolve that no longer exists. And dispose() rejecting every pending promise is the difference between a clean unmount and a component tree waiting forever on a worker that no longer exists; Handling Worker Termination Gracefully in SPAs covers wiring that into route transitions.

Trade-off: a timeout abandons the promise, not the work

Rejecting on the main thread does nothing to the worker, which happily finishes a computation whose result nobody wants — and if the worker is single-threaded and busy, that stale task is still ahead of your next request in the queue. Real cancellation requires cooperation: send a cancel message the worker checks between chunks of work, or run the request against a pool so an abandoned task cannot block the next one. Worker Pool Management covers the dispatch side of that.

Three overlapping requests, replies out of order, and one timeout with a late reply dropped A two-lane sequence. The main thread sends requests 7, 8 and 9 in quick succession; the worker replies to 9 first, then to 7. Request 8 exceeds its five-second timeout, so its pending entry is deleted and its promise rejects; when the worker's reply to 8 finally arrives it finds no entry in the pending map and is dropped instead of resolving the wrong promise. Below the lanes, three bars show how long each id stayed in the pending map: id 7 until its reply landed, id 8 until the timeout fired, id 9 shortest of all. Main thread — pending Map keyed by id send #7 send #8 send #9 #9 resolves #7 resolves #8 times out #8 late — dropped Worker recv #7 recv #8 recv #9 reply #9 reply #7 reply #8, far too late pending id 7 — resolved when its own reply lands id 8 — rejected when the 5 s timer fires, entry deleted id 9 — resolved first Matching is by id, never by arrival order: the late #8 reply finds no entry, so if (!entry) return drops it.
Out-of-order replies are the normal case, not the pathological one. The pending map turns them into a lookup; without it, resolving the oldest promise on every message swaps results as soon as two requests overlap.

Step 3 — Give Each Feature Stream Its Own MessagePort

Once a worker serves more than one concern — say a projection service, a telemetry sink and a tile cache — a single onmessage handler becomes a router that every feature must agree on. Each handler runs for every message, teardown is all-or-nothing, and there is no way to hand one feature’s channel to another consumer without exposing the whole worker. MessageChannel fixes all three: each channel is an independent bidirectional pipe with its own handler, its own lifetime and its own close().

// main-thread: open one channel per feature and transfer the far end to the worker
export interface FeatureChannel {
  send(msg: unknown, transfer?: Transferable[]): void;
  close(): void;
}

export function openFeatureChannel(
  worker: Worker,
  feature: 'projection' | 'telemetry' | 'tiles',
  onMessage: (data: unknown) => void,
): FeatureChannel {
  const { port1, port2 } = new MessageChannel();

  // port2 MUST appear in the transfer list — a MessagePort cannot be cloned.
  worker.postMessage({ kind: 'attach', feature, port: port2 }, [port2]);

  port1.onmessage = (e: MessageEvent) => onMessage(e.data);
  port1.onmessageerror = () => console.error(`[${feature}] undeserializable message`);
  // Assigning .onmessage implicitly starts the port; addEventListener would need port1.start().

  return {
    send: (msg, transfer = []) => port1.postMessage(msg, transfer),
    close: () => { port1.close(); },
  };
}
// projection.worker.js — the worker keeps one port per feature, not one handler for all
const ports = new Map();

self.onmessage = ({ data }) => {
  if (data.kind !== 'attach') return;
  const port = data.port;
  ports.set(data.feature, port);
  port.onmessage = (e) => handleFeature(data.feature, e.data, port);
  port.onmessageerror = () => { port.close(); ports.delete(data.feature); };
};

function handleFeature(feature, msg, port) {
  switch (feature) {
    case 'projection': return port.postMessage(project(msg));
    case 'telemetry':  return void buffer.push(msg);      // no reply expected
    case 'tiles':      return port.postMessage(lookupTile(msg));
  }
}

Because a port is itself transferable, this scales past the page/worker pair: transfer one end of a channel from worker A to worker B and the two communicate directly, without relaying every message through the main thread. That is the single most effective way to keep a busy UI thread out of a worker-to-worker data path.

Trade-off: ports are cheap to create and easy to leak

An entangled MessagePort with a message handler attached is a GC root on both sides — neither port is collectable while the channel is alive, and everything the handler closes over stays alive with it. A single-page app that opens a channel per mounted view and never calls close() accumulates one live port pair per navigation, which is exactly the shape of the retained-message leak in the fleet-dashboard example. Close ports in the same teardown path that removes the view, and confirm with a heap snapshot diff: Identifying Memory Leaks in Workers covers reading the retainer chain.

One MessageChannel per feature, plus a fourth channel that skips the page entirely The main thread holds port1 of three channels — projection, telemetry and tiles — and has transferred port2 of each into Worker A, which keeps them in a map keyed by feature. Each channel is an independent bidirectional pipe with its own handler, its own lifetime and its own close. A fourth channel has both of its ports inside workers: Worker A transferred one end to Worker B, so those two exchange messages directly without relaying anything through the main thread. One MessageChannel per feature — plus one that skips the page entirely Main thread map view metrics panel tile layer one close() per view teardown Worker A project() buffer.push() lookupTile() ports.set(feature, port) projection request / reply, own handler telemetry fire-and-forget, no reply tiles request / reply, own close() worker ⇄ worker — the main thread is not involved Worker B holds a transferred port — a direct pipe
A port is itself transferable, so the topology is not limited to page-to-worker. Handing one end of a channel from Worker A to Worker B keeps a busy UI thread out of a worker-to-worker data path entirely.

Step 4 — Apply Credit-Based Backpressure to Streaming Producers

postMessage never blocks and never fails when the receiver is behind. The message is copied, queued, and control returns immediately, so a producer in a tight loop can enqueue tens of thousands of messages that the consumer will not reach for seconds. Nothing in the platform reports queue depth, so the only workable design is to make the producer keep count.

Credit-based flow control is the standard answer: the consumer grants a bounded number of in-flight messages and returns a credit each time it finishes one. The producer sends only while it holds credit. Window size sets the pipelining depth — a window of 1 is strict lock-step (simple, but the producer idles for a full round trip after every chunk), while a window of 4–8 keeps the consumer continuously fed without letting the queue grow unbounded.

// producer.worker.js — streams a large result set under a credit window
let credit = 0;
let pending = null;        // resolve() of a producer parked waiting for credit
let port = null;

function awaitCredit() {
  if (credit > 0) return Promise.resolve();
  return new Promise((resolve) => { pending = resolve; });
}

self.onmessage = ({ data }) => {
  if (data.kind === 'start') {
    port = data.port;
    credit = data.window ?? 4;                 // initial grant
    port.onmessage = ({ data: ack }) => {
      if (ack.kind !== 'credit') return;
      credit += ack.amount;
      const resume = pending; pending = null;
      resume?.();
    };
    void stream(data.rowCount);
  }
};

async function stream(rowCount) {
  const CHUNK = 16_384;                        // rows per message
  for (let offset = 0; offset < rowCount; offset += CHUNK) {
    await awaitCredit();
    credit -= 1;
    const rows = computeRows(offset, Math.min(CHUNK, rowCount - offset)); // Float32Array
    // Transfer the chunk: ownership moves, so this is O(1) regardless of size.
    port.postMessage({ kind: 'chunk', offset, rows }, [rows.buffer]);
  }
  port.postMessage({ kind: 'end', rowCount });
}
// main-thread consumer — returns credit only after the chunk is actually consumed
const { port1, port2 } = new MessageChannel();
const WINDOW = 4;

port1.onmessage = ({ data }: MessageEvent) => {
  if (data.kind === 'chunk') {
    appendToChart(data.rows as Float32Array);  // synchronous, bounded work
    port1.postMessage({ kind: 'credit', amount: 1 });
  } else if (data.kind === 'end') {
    port1.close();
  }
};

worker.postMessage({ kind: 'start', port: port2, window: WINDOW, rowCount: 2_000_000 }, [port2]);

The ordering matters more than it looks: returning credit before the chunk has been processed reintroduces the unbounded queue one level up, because the producer refills while the consumer’s own work backlog grows. Grant credit at the point the memory is genuinely free again. When the consumer recycles buffers, return the drained ArrayBuffer alongside the credit message and let the producer refill it — that turns the credit protocol into a free-list and eliminates per-chunk allocation entirely, a pattern developed further in Streaming JSON Parsing with Transferable Chunks.

Trade-off: window size trades latency against throughput

Each round trip costs roughly 0.1–0.5 ms of pure signalling on a modern desktop engine, so a window of 1 caps throughput at a few thousand chunks per second no matter how small the chunks are. Widening the window hides that latency behind useful work but raises worst-case memory to window × chunkSize and lengthens the tail on cancellation, because everything already granted still arrives. Size the chunk so one unit of consumer work fits inside a frame — for a 16.7 ms budget with ~4 ms of rendering, aim for chunks the consumer handles in 2–4 ms — then set the window to 4 and measure before changing it.

In-flight chunks over time under a credit window of four, against an uncontrolled producer A step chart of unacknowledged chunks over the life of a stream. Under a window of four the count climbs one step per chunk to the cap, flattens while the producer is parked awaiting credit, then sawtooths against the cap as each returned credit is immediately spent, and finally drains to zero when the stream ends. A dashed diagonal shows the same producer with no window at all: the count rises without bound because the message queue absorbs everything the consumer has not reached. Three cards below compare a window of one, a window of four to eight, and no window. Credit window = 4: in-flight chunks can never exceed the grant in-flight chunks 4 3 2 1 0 window = 4 no window: the queue absorbs it all producer parked, credit 0 burst start steady state — one credit in, one chunk out stream ends window = 1 strict lock-step — the producer idles for a full round trip after each chunk window = 4–8 keeps the consumer continuously fed; worst-case memory = window × chunk no window at all the queue becomes the buffer and grows until the tab is killed
The sawtooth against the cap is the healthy shape: every returned credit is spent immediately, so the consumer never starves and the queue never grows. Wide gaps in the sawtooth mean the window is too narrow; a rising line means there is no window at all.

Step 5 — Coalesce High-Frequency Updates into One Message per Frame

Flow control bounds the queue; coalescing removes messages that never needed to be sent. When a source emits faster than the display refreshes, every update but the last in each frame is dead on arrival — nobody can see it. Buffer by entity key, flush once per requestAnimationFrame, and the message rate is pinned at 60 per second regardless of whether the feed delivers 200 or 20,000 events in that time.

// coalescer.ts — collapses a burst into one message per animation frame
export class FrameCoalescer<T> {
  readonly #pending = new Map<number, T>();     // last value wins, keyed by entity id
  #frame = 0;
  #droppedSinceFlush = 0;

  constructor(
    private readonly flush: (batch: Map<number, T>, dropped: number) => void,
    private readonly maxEntries = 5_000,        // hard cap: shed load rather than grow
  ) {}

  push(id: number, value: T): void {
    if (!this.#pending.has(id) && this.#pending.size >= this.maxEntries) {
      this.#droppedSinceFlush++;                // export this counter; silent drops hide incidents
      return;
    }
    this.#pending.set(id, value);               // supersedes any earlier value for this id
    this.#frame ||= requestAnimationFrame(() => {
      this.#frame = 0;
      const batch = new Map(this.#pending);
      const dropped = this.#droppedSinceFlush;
      this.#pending.clear();
      this.#droppedSinceFlush = 0;
      this.flush(batch, dropped);
    });
  }

  dispose(): void {
    if (this.#frame) cancelAnimationFrame(this.#frame);
    this.#pending.clear();
  }
}

Pack the flushed batch into typed arrays rather than posting the Map. A batch of 4,000 vehicle updates as plain objects is roughly 4,000 heap allocations to serialize and 4,000 more to materialize; the same batch as one Int32Array of ids plus one Float32Array of interleaved coordinates is two allocations and a memcpy, and both buffers can be transferred instead of copied:

const coalescer = new FrameCoalescer<Vehicle>((batch, dropped) => {
  const ids = new Int32Array(batch.size);
  const coords = new Float32Array(batch.size * 3);   // lon, lat, heading
  let i = 0;
  for (const [id, v] of batch) {
    ids[i] = id;
    coords[i * 3] = v.lon; coords[i * 3 + 1] = v.lat; coords[i * 3 + 2] = v.heading;
    i++;
  }
  // Both buffers are freshly allocated here, so transferring them is always safe.
  worker.postMessage({ kind: 'positions', ids, coords, dropped }, [ids.buffer, coords.buffer]);
});

socket.addEventListener('message', (e) => {
  const v = JSON.parse(e.data) as Vehicle;
  coalescer.push(v.id, v);
});

One caveat that catches teams out: coalescing is correct only for state — where the newest value fully replaces the previous one. It is wrong for events, where each occurrence carries meaning (a click, an appended log line, a financial tick). Coalesce positions and progress percentages; batch events into an array instead, and drop from the front with an explicit counter if you must shed load. A background tab makes this concrete: requestAnimationFrame stops firing entirely, so a coalescer keyed on state simply holds the latest value until the tab is visible again, while an event batcher must have a size cap or it grows for as long as the tab is hidden.

One frame of input under two correct policies: last-wins for state, append-and-shed for events The upper lane shows twelve state updates arriving in a single frame for three entities A, B and C. Keyed by entity id, each new value supersedes the previous one, so the flush carries only three surviving values in one postMessage. The lower lane shows twelve discrete events in the same frame. Because each occurrence carries meaning, they are appended to an array rather than collapsed; the array is capped at ten, so the two oldest are shed and reported as an explicit dropped counter alongside the batch. One frame of input, two correct policies State — last write wins positions · progress · cursors A1 B1 A2 C1 A3 B2 C2 A4 B3 C3 C4 C5 A4 B3 C5 postMessage(3) 12 pushes across 3 entities → 3 keys, one message. Superseded values were dead on arrival — nobody could have seen them. Events — append, then shed with a counter clicks · log lines · price ticks e1 e2 e3 e4 e5 e6 e7 e8 e9 e10 e11 e12 batch of 10 cap reached, oldest 2 shed postMessage 12 events → 10 delivered, with dropped: 2 reported alongside the batch. Coalescing would be wrong here — each occurrence carries meaning, so shed explicitly instead.
The dashed chips are the two shed events. Collapsing them the way the upper lane collapses positions would lose occurrences silently; shedding them with a counter loses the same data loudly, which is the difference between a known limit and an unexplained incident.

Choosing the Transport for Each Message Class

A single worker protocol normally uses all three transports at once, chosen per message class rather than per application. The deciding factors are payload shape and who owns the bytes afterwards.

Message class Transport Typical cost Ownership after send
Control: start, cancel, credit, config Structured clone Microseconds — a few fields Sender keeps its copy
Bulk numeric: chunks, frames, tiles, coordinate batches Transferable ArrayBuffer ~0.02 ms, independent of size Sender’s buffer is detached
Continuous shared state: ring buffers, audio, cursor positions SharedArrayBuffer + Atomics No copy; one notify per batch Both threads own it concurrently
Rendering surfaces Transferable OffscreenCanvas One-off hand-off Main thread loses draw access

The practical rule: clone anything small enough that measuring it would cost more than sending it, transfer anything bulk the sender is finished with, and only reach for shared memory when the same bytes are read continuously by more than one thread. Transfer is the workhorse — moving a Float32Array of a million elements is a pointer hand-off rather than a copy, which is why Transferable Objects & Zero-Copy is the first optimisation to reach for once a payload stops being a control message. Shared memory removes even the hand-off but replaces it with synchronisation you have to get right yourself; postMessage vs SharedArrayBuffer: When to Choose Each walks that comparison in full.

Note that transfer and clone are not exclusive within a single message: the transfer list applies to the listed objects while everything else in the same envelope is cloned normally. That is exactly what Step 1’s reply does — a small cloned envelope carrying two transferred buffers.

COOP / COEP required for SharedArrayBuffer

Shared memory only exists in a cross-origin isolated context. The document must be served with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, and every cross-origin subresource must opt in via CORS or Cross-Origin-Resource-Policy. Without both headers SharedArrayBuffer is undefined, and the failure appears at construction rather than at use — including in same-origin workers. Gate the shared-memory path on globalThis.crossOriginIsolated === true and keep the transferable path as the fallback, because isolation is routinely broken later by an embed or analytics tag that nobody thought of as a transport dependency.

Choosing a transport per message class: clone, transfer or shared memory A decision tree starting from a message about to be sent. If it is a small control message, use structured clone and the sender keeps its copy. Otherwise, if the sender is finished with the bytes, transfer them: the buffer detaches and the cost is constant regardless of size. Otherwise, if the same bytes are read continuously by both threads, use a SharedArrayBuffer, gated on globalThis.crossOriginIsolated being true. If none of those hold, clone it — the sender still needs its own copy and the default is fine. a message to send a small control message? is the sender done with the bytes? read continuously by both threads? yes yes yes no no no Structured clone sender keeps its own copy Transferable ~0.02 ms; the buffer detaches SharedArrayBuffer gate on crossOriginIsolated Clone it anyway you still need your own copy The branches are not exclusive within one call: listed buffers move while the rest of the envelope is copied.
The question is asked per message class, not per application. A single protocol normally lands on all three leaves at once — cloned control messages, transferred bulk payloads, and shared memory only where both threads read the same bytes continuously.

Verification & Measurement

A protocol change is only verifiable if you can attribute time to each hop. The three quantities that matter are queue wait (how long a message sat undelivered), transport cost (serialize plus deserialize) and handler cost. Instrument all three with User Timing, which is available in workers.

Timestamps are the trap: each worker gets its own performance.timeOrigin, fixed when the worker global scope is created, so raw performance.now() values are not comparable across threads. Normalise to absolute epoch milliseconds on both sides before subtracting.

// clock.ts — shared by both threads
export const epochNow = (): number => performance.timeOrigin + performance.now();
// worker side: stamp receipt and completion using the shared clock
self.onmessage = ({ data }) => {
  const receivedAt = performance.timeOrigin + performance.now();
  performance.mark(`handle-${data.id}-start`);
  const result = handle(data);
  performance.mark(`handle-${data.id}-end`);
  performance.measure(`handle-${data.kind}`, `handle-${data.id}-start`, `handle-${data.id}-end`);
  self.postMessage({ ...result, id: data.id, sentAt: data.sentAt, receivedAt,
                     repliedAt: performance.timeOrigin + performance.now() });
};

With sentAt stamped by the sender and both worker timestamps returned, the main thread can decompose every round trip:

port1.onmessage = ({ data }: MessageEvent) => {
  const now = epochNow();
  const queueWait = data.receivedAt - data.sentAt;   // clone + delivery + time queued
  const handler   = data.repliedAt - data.receivedAt;
  const returnLeg = now - data.repliedAt;
  metrics.record({ queueWait, handler, returnLeg, inFlight: client.inFlight });
};

Read the result against these acceptance thresholds:

  • queueWait under ~1 ms and flat as load rises — delivery is healthy. If it climbs steadily under load, the consumer is behind and the queue is absorbing the difference: that is the signature Step 4 exists to remove.
  • inFlight oscillating within its window and never trending upward. A monotonically rising in-flight count over a minute of steady input is the earliest reliable warning of a protocol that will fall over, and it shows up long before memory does.
  • Message rate capped at the refresh rate after Step 5. Count postMessage calls per second in a burst; the number should sit at 60 whether the source emits 200 or 20,000 events per second.
  • Sender-side postMessage cost flat as payload size grows. If it scales with size, the transfer list is not being applied — a rename or a nesting change is the usual cause.

Confirm the last one directly, since a failed transfer degrades silently into a copy:

function assertTransferred(buffer: ArrayBuffer, label: string): void {
  // A transferred buffer reports byteLength 0 on the sending thread.
  if (buffer.byteLength !== 0) console.warn(`[protocol] ${label} was cloned, not transferred`);
}

Finally, check the shape in a Performance recording: the main-thread track should show short handler tasks at a steady cadence rather than one long task per burst, and the worker track should show continuous utilisation rather than sawtooth idle gaps. Sawtooth gaps under load mean the window is too narrow and the producer is starving between credits.

Decomposing one round trip into queue wait, handler cost and return leg One request travels from the main thread to the worker and back, stamped at four points. sentAt is taken on the main thread before the send; receivedAt at the top of the worker's handler; repliedAt just before the worker posts its reply; and arrival on the main thread when the reply lands. The three gaps between them decompose the round trip into queueWait, which covers clone, delivery and time spent queued; handler, which is the work itself; and returnLeg, the reply's clone and delivery. Because each thread has its own performance.timeOrigin, every stamp must be normalised to timeOrigin plus performance.now before any two are subtracted. Each thread has its own timeOrigin — normalise to timeOrigin + now() before subtracting Main thread sentAt arrival — epochNow() Worker handler runs receivedAt repliedAt queueWait handler returnLeg clone + delivery + time queued the work itself reply clone + delivery
Only the middle segment is the worker's own cost. A queueWait that climbs with load while handler stays flat is the exact signature Step 4 removes — and it is invisible unless all four stamps ride on the same normalised clock.

Failure Modes & Error Handling

Four failures are specific to the message boundary, and none of them produce a useful default error.

Non-cloneable values throw synchronously. Functions, Symbols, DOM nodes and class instances carrying methods raise DataCloneError from inside postMessage, on the calling thread. Because it throws where it is called rather than where the message is consumed, wrapping the send is enough to get an actionable log:

function postChecked(target: Worker | MessagePort, msg: unknown, transfer: Transferable[] = []): void {
  try {
    target.postMessage(msg, transfer);
  } catch (err) {
    if (err instanceof DOMException && err.name === 'DataCloneError') {
      console.error('[protocol] non-cloneable value in message', { msg });
    }
    throw err;   // never swallow: the request will otherwise hang forever
  }
}

Deserialization failures fire messageerror, not message. If a value serializes on the sender but cannot be reconstructed on the receiver, the message handler never runs and the request hangs. Register messageerror on the worker, on the global scope inside the worker, and on every MessagePort — a port with only onmessage set will drop these silently.

Errors lose their prototype in transit. A thrown Error structured-clones in modern engines, but subclasses arrive as plain Error and custom fields on the prototype are gone, so err instanceof MyDomainError is always false on the far side. Serialize deliberately and rebuild:

// worker.js — catch at the dispatcher boundary, never let a rejection escape unreported
self.addEventListener('unhandledrejection', (e) => {
  self.postMessage({ id: currentId, ok: false, error: toWire(e.reason) });
  e.preventDefault();
});

const toWire = (e) => ({
  name: e?.name ?? 'Error',
  message: e?.message ?? String(e),
  stack: e?.stack,
  code: e?.code,                    // carry your own discriminant across the boundary
});

Structured Error Serialization Across Threads covers the reporting side, including keeping worker stack frames usable in production telemetry.

A dead worker never replies. A top-level throw, an OOM kill or a terminate() mid-flight leaves every pending promise unsettled, and by default nothing notices — the UI just stops. The #failAll path in Step 2 is the guard, wired to both error and messageerror. Restart policy belongs with it: recreate the worker, replay only idempotent requests, and cap retries so a request that reliably crashes the worker cannot loop. Whichever policy you choose, make the pending map the single source of truth for what is outstanding — anything not tracked there cannot be recovered.

Four boundary failures, their observable symptom and the guard that catches each A two-by-two matrix. DataCloneError is triggered by a function, Symbol or DOM node in the payload and throws synchronously inside postMessage on the calling thread; the guard is a wrapped send that logs the payload and rethrows. A messageerror event is triggered by a value that serializes but cannot be reconstructed, and shows up as a silent hang because the message handler never runs; the guard is registering messageerror on the worker, on self inside the worker, and on every port. A stripped Error prototype is triggered by any Error subclass crossing the boundary, and shows up as instanceof always returning false; the guard is serializing name, message, stack and your own code field. A dead worker is triggered by a top-level throw, an out-of-memory kill or a terminate mid-flight, and shows up as every pending promise staying unsettled; the guard is failing all pending entries from both the error and messageerror handlers. Four failures at the boundary — none produce a useful default error DataCloneError Trigger — a function, Symbol or DOM node Symptom — throws synchronously, inside the call Guard — wrap the send, log the payload, rethrow messageerror Trigger — the value cannot be deserialized Symptom — silent hang; message never fires Guard — register it on worker, self and each port Error prototype stripped Trigger — an Error subclass crosses the boundary Symptom — instanceof is always false on the far side Guard — send name, message, stack and your own code Dead worker Trigger — top-level throw, OOM kill or terminate() Symptom — every pending promise stays unsettled Guard — #failAll() from error and messageerror
Three of the four are silent by default: only DataCloneError announces itself, and it does so on the sending thread rather than where the message was going to be consumed.

Browser Compatibility

Feature Chrome Firefox Safari Edge
postMessage + structured clone 4+ 3.5+ 4+ 12+
MessageChannel / MessagePort 4+ 41+ 5+ 12+
Transfer list in postMessage 17+ 18+ 5.1+ 12+
structuredClone() global 98+ 94+ 15.4+ 98+
messageerror event 60+ 57+ 12.1+ 79+
Module workers ({ type: 'module' }) 80+ 114+ 15+ 80+
Transferable ImageBitmap 50+ 42+ 15+ 79+
Transferable OffscreenCanvas 69+ 105+ 16.4+ 79+
Transferable streams 87+ 103+ 16.4+ 87+
User Timing (mark/measure) in workers 45+ 41+ 11+ 79+
SharedArrayBuffer (cross-origin isolated) 92+ 79+ 15.2+ 92+

Everything in Steps 1 through 5 — envelopes, correlation IDs, ports, credit windows and frame coalescing — runs on every browser in the table; the primitives involved have been universally supported for over a decade. The compatibility cliffs sit in the optimisations layered on top: module workers need Firefox 114+, transferable OffscreenCanvas needs Firefox 105+, and shared memory needs both a modern engine and correctly configured isolation headers. Build the protocol on clone-plus-transfer, gate anything shared-memory-based behind crossOriginIsolated, and the same code ships everywhere.


Going Further

Two extensions of this protocol design deserve their own treatment. Designing Versioned Message Protocols covers what happens when a cached page and a freshly fetched worker chunk come from different builds — a routine deployment state that turns a renamed field into silence rather than an error. Using MessageChannel for Worker-to-Worker Links covers the topology change that removes the page from the data path entirely, which is worth doing once message rates pass roughly a hundred per second.

Frequently Asked Questions

When should I use MessageChannel instead of calling postMessage on the worker directly?
Use a MessageChannel as soon as a worker serves more than one independent concern. worker.postMessage funnels every feature through a single onmessage handler, so every listener pays the dispatch cost of every message and one careless handler can swallow unrelated traffic. A dedicated port per feature gives you independent routing, independent teardown via port.close(), and the ability to hand a port to a third party — including a second worker — without exposing the whole worker. A single request/response service does not need it.
How do I stop a fast producer from flooding a slower consumer?
Give the sender a bounded budget it cannot exceed without permission. Two mechanisms work: credit-based flow control, where the receiver grants N in-flight messages and returns credit as it drains them, and frame-aligned coalescing, where a burst collapses into one message per requestAnimationFrame tick. Both require your own in-flight counter, because the platform exposes no queue-depth API — an undrained message queue simply grows until the tab is killed.
What actually causes DataCloneError, and what should I send instead?
The Structured Clone Algorithm refuses functions, Symbols, DOM nodes, WeakMap/WeakSet and most host objects, and it throws synchronously inside the postMessage call. Class instances are a subtler trap: they clone successfully but arrive as plain objects with no prototype, so the failure surfaces later as x.method is not a function. Send plain data — flat objects, arrays, typed arrays — and rebuild behaviour on the receiving side. The Step-by-Step Guide to the Structured Clone Algorithm walks the exact traversal rules.
Can a Web Worker communicate synchronously with the main thread?
No. Every postMessage delivery is queued as a task on the receiving thread’s event loop. Atomics.wait() can block a worker while it waits on a shared memory word, but it throws a TypeError on the main thread, so the main thread can never block on a worker. Synchronous-looking APIs are always Promises layered over an asynchronous protocol; see SharedArrayBuffer & Atomics for the shared-memory signalling path.

See also