postMessage vs SharedArrayBuffer: When to Choose Each
Both mechanisms move data across a thread boundary, but only one of them makes you responsible for memory ordering — and that is the real decision, not the benchmark.
This page narrows the shared-memory model documented in SharedArrayBuffer & Atomics, part of the Web Workers Architecture & Communication reference, to a single question: for the payload in front of you right now, which transport is cheapest that is still correct? The answer is decided by four properties of the payload — how many agents touch it, how large it is, how often it moves, and whether your deployment can carry cross-origin isolation — and the order you ask them in matters, because each one eliminates options faster than the next.
SharedArrayBuffer exists only on cross-origin-isolated pages. The document response must carry Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, and every cross-origin subresource must then be CORP- or CORS-eligible. One non-compliant ad tag, embedded video or analytics image keeps crossOriginIsolated at false and the constructor stays undefined — so this is a deployment decision as much as a code one, and it belongs at the start of the evaluation rather than at the end.
The Three Transports, Precisely
Comparisons usually pit two things against each other. There are three, and the middle one — transfer — is the option most decisions should land on.
Structured clone, worker.postMessage(value), walks the object graph, serializes it, and rebuilds an independent copy in the receiver’s heap. Cycles and shared references survive, Map, Set, Date and RegExp keep their identity as types, and both sides end up owning a live value. Cost scales with the graph, not just the byte count; the mechanics are covered in the Step-by-Step Guide to the Structured Clone Algorithm.
Transfer, worker.postMessage(value, [buffer]), hands over the memory instead of copying it. The sender’s view is detached and its byteLength reads 0; exactly one agent owns the bytes at any instant, which is why this transport cannot race by construction. Only ArrayBuffer, MessagePort, ReadableStream, WritableStream, TransformStream, ImageBitmap, OffscreenCanvas and VideoFrame are transferable — see Transferable Objects & Zero-Copy.
Shared memory, a SharedArrayBuffer posted to each agent, maps the same physical pages into several threads at once. Nothing is delivered and nothing is owned: a write in one worker is a write in all of them, subject to whatever ordering you enforce with Atomics. It is the only transport with no delivery step, and the only one where a plain view[i] = x is a correctness bug rather than a style choice.
Minimal Reproducible Example
The fastest way to end a transport argument is to measure the actual payload. This harness sends the same 8 MB Float32Array three ways and reports the cost of each, including the half of structured clone that is usually invisible.
// transport-bench.ts — main thread
const SAMPLES = 2_000_000; // 8 MB as Float32
const worker = new Worker(new URL('./bench.worker.ts', import.meta.url), { type: 'module' });
function echo(payload: unknown, transfer: Transferable[] = []): Promise<number> {
return new Promise((resolve) => {
const t0 = performance.now();
worker.addEventListener('message', function done({ data }) {
worker.removeEventListener('message', done);
void (data as { echo?: Float32Array }).echo?.length; // force lazy deserialization
resolve(performance.now() - t0);
});
worker.postMessage(payload, transfer);
});
}
// 1. Structured clone — the sender pays serialize, the receiver pays rebuild.
const cloneMs = await echo({ kind: 'clone', echo: new Float32Array(SAMPLES) });
// 2. Transfer — ownership moves; the sender's view is detached afterwards.
const moved = new Float32Array(SAMPLES);
const transferMs = await echo({ kind: 'transfer', echo: moved }, [moved.buffer]);
console.assert(moved.byteLength === 0, 'sender view should be detached');
// 3. Shared memory — posted once, then only signals cross the boundary.
const sab = new SharedArrayBuffer(SAMPLES * Float32Array.BYTES_PER_ELEMENT + 4);
const signal = new Int32Array(sab, 0, 1); // slot 0: the wake flag
worker.postMessage({ kind: 'shared', sab }); // NOT in the transfer list
const t0 = performance.now();
Atomics.store(signal, 0, 1);
Atomics.notify(signal, 0); // wake the parked worker
const { value } = Atomics.waitAsync(signal, 0, 1);
await value; // worker sets slot 0 back to 0
const signalMs = performance.now() - t0;
// bench.worker.js — worker side
let shared = null;
self.onmessage = ({ data }) => {
if (data.kind === 'shared') {
shared = new Int32Array(data.sab, 0, 1);
// Park until the main thread flips slot 0. Legal here; illegal on the main thread.
while (Atomics.wait(shared, 0, 0) === 'ok') {
Atomics.store(shared, 0, 0);
Atomics.notify(shared, 0); // hand the turn back
}
return;
}
// Echo the payload back the way it arrived: cloned in, cloned out; moved in, moved out.
const transfer = data.kind === 'transfer' ? [data.echo.buffer] : [];
self.postMessage(data, transfer);
};
Step-by-Step Walkthrough
void data.echo?.lengthinside the timed region is the line that makes the numbers honest. Blink deserializes lazily, on the first read ofevent.data— leave that read out and the clone’s receiving half lands in a later task, where it looks like your handler being slow rather than the transport being expensive. Measuring Structured Clone Cost with performance.now() breaks the two halves apart properly.[moved.buffer], not[moved]— the transfer list takes the underlyingArrayBuffer, never the typed-array view. Passing the view throwsDataCloneError, which is the single most common first-attempt failure with transfers.console.assert(moved.byteLength === 0)proves the handoff happened rather than assuming it. A transfer that silently fell back to a clone — because the buffer never reached the transfer list — is invisible in the timings on small payloads and obvious only at scale.worker.postMessage({ kind: 'shared', sab })has an empty transfer list. ASharedArrayBufferis cloned by reference: every recipient ends up mapped to the same pages. Putting it in the transfer list is an error, and it is the second most common first-attempt failure.Atomics.waitAsyncon the main thread,Atomics.waitin the worker. The blocking form throws aTypeErroron the main thread by design — it would freeze rendering, input and timers. The async form returns{ async, value }wherevaluesettles to"ok"or"timed-out", keeping the event loop turning. Coordinating Workers with Atomics.wait and notify covers the wake protocol, including the lost-wakeup race this loop is written to avoid.- The shared measurement is not comparable to the other two, and that is the point. Cases 1 and 2 time an 8 MB payload crossing a boundary; case 3 times a signal, because the bytes never cross at all. Shared memory does not make the transfer faster — it removes the transfer.
The Decision Rubric
Work through these in order and stop at the first one that answers. They are ordered by how decisively they eliminate options, not by how often they apply.
1. Do two or more agents need the same bytes at the same time?
If yes → SharedArrayBuffer. Nothing else can express it: clone gives every agent a private copy, and transfer gives exactly one agent the original. Ring buffers, shared frame stores, cross-worker counters and WebAssembly threads all land here.
2. Is a large binary payload (> 1 MB) moving one way, once? If yes → transfer. Zero copy, no headers, no ordering rules, and the detached sender view turns a whole class of use-after-send bugs into an immediate exception.
3. Is the payload small (< 100 KB) or structurally rich — nested objects, Maps, Dates, cycles?
If yes → structured clone. Below the message-hop floor the copy is not measurable, and the fidelity is worth more than the microseconds.
4. Must coordination itself beat a message hop?
If yes → Atomics.wait / Atomics.notify over a shared buffer. A futex wake is 10–20 µs against a 0.1–0.5 ms hop; audio callbacks, simulation ticks and lock-free queues need that margin, request/response pipelines do not.
5. Can you serve COOP and COEP without breaking an embed? If no → shared memory is off the table regardless of the answers above. Fall back to transferable ping-pong: producer fills a buffer, transfers it, consumer processes and transfers it back.
6. Is this code going to be maintained by someone other than you?
Prefer messages where the first five questions leave it open. A postMessage payload is visible in the DevTools performance panel and reproducible in a unit test; a shared-memory race reproduces on one machine, under load, in one build.
| Scenario | Transport |
|---|---|
| One-off buffer > 1 MB, single direction | Transfer (postMessage + transfer list) |
| Small or structurally rich payload | Structured clone |
| Several workers reading/writing one region | SharedArrayBuffer + Atomics |
| Sub-millisecond signalling between agents | SharedArrayBuffer + Atomics.wait/notify |
| Lock-free queue or ring buffer | SharedArrayBuffer + Atomics |
| Audio worklet ↔ worker handoff | SharedArrayBuffer + Atomics |
| Third-party embeds block isolation | Transfer, or clone |
| Task dispatch, config, errors, results | Structured clone |
What Hybrid Architectures Look Like
The rubric is applied per payload, not per application, so a mature worker system uses all three at once — and that layering is the normal design rather than a compromise.
- Control plane — structured clone. Task dispatch, configuration, lifecycle, cancellation, progress and errors. Infrequent, small, and worth being able to read in a profile. Errors especially: a shared status word tells you that something failed, while a cloned message tells you what, which is why structured error serialization stays on the message path.
- Data plane — shared memory. The hot region several agents touch continuously: a ring buffer of audio frames, a simulation state array, a shared sample store. Posted once at startup, then never re-sent.
- Result path — transfer. A finished frame, a decoded image, a computed
ArrayBufferhanded back for rendering. One owner, one direction, arbitrary size, no isolation requirement.
// pipeline.ts — one worker, three transports, each on the payload it fits
const sab = new SharedArrayBuffer(RING_BYTES); // data plane, allocated once
const worker = new Worker(new URL('./pipeline.worker.ts', import.meta.url), { type: 'module' });
worker.postMessage({ type: 'INIT', config: { fps: 60, quality: 'high' }, sab }); // control + handle
worker.onmessage = ({ data }) => {
switch (data.type) {
case 'FRAME': // transferred back, zero-copy
renderFrame(new Uint8ClampedArray(data.buffer as ArrayBuffer));
break;
case 'ERROR': // cloned, so it survives with detail
report(data.name, data.message, data.stack);
break;
}
};
Gotchas & Edge Cases
A shared buffer in the transfer list throws
SharedArrayBuffer is never transferred. Listing it raises DataCloneError: SharedArrayBuffer at index 0 could not be cloned, and the reflex fix — copying the pattern that works for ArrayBuffer — is exactly wrong. Post it as an ordinary message property; sharing is the default, not an opt-in.
The clone you removed was never the bottleneck
Replacing a 40 KB message with a shared buffer trades a sub-100 µs copy for a memory model. If the profile shows 0.3 ms per message and 0.28 ms of it is the hop itself, shared memory cannot recover it — only sending fewer, larger messages can. Measure the split before restructuring anything.
Isolation is retroactive on your whole page
COOP and COEP apply to the document, not to the worker. Turning them on to gain SharedArrayBuffer can break an embedded map, video, ad slot or analytics pixel that does not send Cross-Origin-Resource-Policy. Cross-Origin-Embedder-Policy: credentialless relaxes this for no-credential subresources in Chrome 96+ and Firefox 119+ but not in Safari, so the safe order is: verify isolation in staging with the real third-party set, then write the shared-memory code. Debugging SharedArrayBuffer Cross-Origin Errors covers the diagnosis when crossOriginIsolated stays false.
A transferred buffer cannot be sent twice
After a transfer the sender’s buffer is detached, so a retry, a queued duplicate, or the same buffer listed twice in one call throws DataCloneError: ArrayBuffer at index 0 is already detached. Retry logic must re-acquire ownership — usually by having the worker transfer the buffer back — rather than re-send. Shared memory has no equivalent hazard, which is a genuine point in its favour for bidirectional pipelines that need retries.
Performance Note
Structured clone runs at roughly 0.7 ms per megabyte for a flat Float32Array — paid twice, serialize and rebuild — and ten to thirty times that for a dense object graph of the same weight. A transfer of the same buffer costs under 0.05 ms at any size. A message hop has a 0.1–0.5 ms floor no payload can go below, while an Atomics.notify → Atomics.wait wake lands at 10–20 µs and Atomics.waitAsync at roughly 0.15 ms, dominated by Promise microtask scheduling.
The rule of thumb that survives contact with production: compare the copy against the hop, not against zero. Below about 100 KB of structured data the clone disappears inside the 0.1–0.5 ms hop and optimising it is wasted effort. Above about 1 MB of binary data the clone dominates the hop and a transfer removes it entirely, at no cost in complexity. Shared memory only enters the calculation when the hop itself is the problem — when signalling has to happen faster than once every 0.1 ms, or when two agents genuinely need the same bytes — and at that point you are buying latency with a memory model, which is a trade to make deliberately rather than by default.
| Payload | Clone | Transfer | Shared |
|---|---|---|---|
| 1 KB control message | ~0.12 ms round trip | not applicable | ~0.02 ms via signal |
1 MB Float32Array |
~0.7 ms each way | <0.05 ms | 0 — no transfer occurs |
10 MB Float32Array |
~7 ms each way, ~14 ms round trip | <0.05 ms | 0 — no transfer occurs |
| 10 MB nested object graph | ~70–200 ms each way | not transferable | not applicable |
| Peak memory during the move | ~2× payload | 1× — ownership moves | 1× — one allocation |
| Wake one waiting agent | 0.1–0.5 ms | 0.1–0.5 ms | 10–20 µs |
Atomics wake is less than one. Compare every bar against the shaded band, never against zero.Browser Support and the Fallback Path
| Mechanism | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
postMessage structured clone |
all | all | all | all |
postMessage transfer (ArrayBuffer) |
17+ | 20+ | 5.1+ | 12+ |
crossOriginIsolated |
87+ | 72+ | 15.2+ | 87+ |
SharedArrayBuffer (cross-origin isolated) |
92+ | 79+ | 15.2+ | 92+ |
Atomics.wait / notify (workers) |
68+ | 78+ | 15.2+ | 79+ |
Atomics.waitAsync |
87+ | 106+ | 16.4+ | 87+ |
COEP: credentialless |
96+ | 119+ | not supported | 96+ |
Safari sets the floor at 15.2 for shared memory and 16.4 for Atomics.waitAsync, and older Safari exposed the Atomics namespace without a usable SharedArrayBuffer — so feature-detect the constructor and crossOriginIsolated, never the namespace. Because isolation depends on headers you may not fully control, the durable shape is a runtime split rather than a build-time one: the same algorithm, with shared memory behind a crossOriginIsolated check and a transferable ping-pong path behind it.
// transport.ts — one algorithm, two transports, chosen at runtime
export const canShare =
typeof SharedArrayBuffer === 'function' && globalThis.crossOriginIsolated === true;
// Shared: fine-grained, ~15 µs per handoff, buffer posted once.
// Fallback: coarser batches to amortise the ~0.2 ms round trip of a ping-pong transfer.
export const batchSize = canShare ? 256 : 8192;
The fallback is not a degraded product — it is the same pipeline with larger batches. Ping-pong transfer costs roughly 0.2 ms per round trip against 15 µs for an Atomics handoff, so multiplying the batch size by an order of magnitude restores the throughput and pays for it in latency, which is usually the cheaper currency outside audio and simulation loops.