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.
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.
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 canimportthe 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.messageerrorhandlers registered on both sides. Deserialization failures do not firemessage, and a codebase that only registersonmessagewill 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,SharedArrayBufferdoes 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);
}
};
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
queueWaitunder ~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.inFlightoscillating 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
postMessagecalls per second in a burst; the number should sit at 60 whether the source emits 200 or 20,000 events per second. - Sender-side
postMessagecost 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.
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.
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.