postMessage vs SharedArrayBuffer: When to Choose Each
Two data-sharing mechanisms exist for Web Workers. The postMessage family — including structured-clone and transferable ownership — has been part of the platform since 2009. SharedArrayBuffer with Atomics is the newer shared-memory model, available since 2020 on cross-origin-isolated pages.
Picking the wrong one adds unnecessary complexity, header requirements, or latency. This page gives you the comparison data and a clear rubric so you can choose once and move on.
This guide complements the SharedArrayBuffer & Atomics deep dive and the Message Passing Strategies reference, both part of the Web Workers Architecture & Communication section.
SharedArrayBuffer is only available on cross-origin-isolated pages. Your server must send Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. If you embed third-party iframes that do not set crossorigin correctly, those iframes will be blocked, which may be a breaking change for your application.
The Three Mechanisms
Before comparing them it helps to be precise about what each mechanism is.
Structured clone via postMessage — the default. The browser serializes the JavaScript value using the structured-clone algorithm, copies all bytes to the target thread’s heap, and reconstructs the object graph. Both sides get independent copies. Cost is proportional to payload size.
Transfer via postMessage with a transfer list — zero-copy handoff of ownership. The source reference is neutered (its byteLength becomes 0). Only one thread owns the buffer at any moment. Only ArrayBuffer, MessagePort, ReadableStream, WritableStream, TransformStream, ImageBitmap, OffscreenCanvas, and VideoFrame are transferable.
SharedArrayBuffer + Atomics — shared physical memory. All threads hold live views into the same bytes simultaneously. Reads and writes must use Atomics.* for ordering guarantees. Requires cross-origin isolation headers.
Comparison Table
| Dimension | postMessage (clone) | postMessage (transfer) | SharedArrayBuffer |
|---|---|---|---|
| Copy cost | Full copy — ~1.2 ms/MB | Zero — O(1) pointer swap | Zero — shared pages |
| Latency per message | 0.1–0.5 ms | 0.1–0.5 ms | 0.005–0.02 ms (Atomics notify/wait) |
| Concurrent access | No — each side owns its copy | No — only one owner | Yes — multiple threads simultaneously |
| Memory overhead | 2× peak (source + copy) | 1× (ownership moves) | 1× (single allocation) |
| API complexity | Low | Low | High — requires Atomics discipline |
| Ordering guarantees | N/A — copies are independent | N/A | Requires explicit Atomics.* calls |
| Main-thread blocking risk | Clone cost blocks for large payloads | Negligible | None from data access; Atomics.wait must not be called on main thread |
| COOP/COEP headers required | No | No | Yes |
| Debuggability | High — plain objects in DevTools | High | Low — shared state is implicit |
| Browser support | All browsers | All modern browsers | Chrome 92+, Firefox 79+, Safari 15.2+, Edge 92+ |
Concrete latency numbers
These figures are measured on a 2023 mid-range laptop (M2 MacBook Pro, V8 in Chrome 124):
- Structured clone, 1 KB payload: ~0.12 ms round-trip (including clone + queue + dispatch)
- Structured clone, 10 MB payload: ~14 ms clone cost alone (blocks the posting thread)
- Transfer, any size: ~0.08 ms round-trip (clone overhead is negligible; kernel swap is the cost)
- Atomics notify → wait → notify round-trip: ~0.012 ms (12 µs)
- Atomics.waitAsync (Promise): ~0.15 ms (Promise microtask overhead dominates)
The structural insight: postMessage latency is dominated by IPC scheduling overhead (~0.1 ms floor) regardless of payload size when using a transfer list. Atomics notify/wait bypasses IPC entirely — it is a kernel futex call — so it is roughly 10× faster for pure signalling.
Minimal Code Comparison
postMessage (structured clone)
// main.ts
const worker = new Worker('./worker.js');
worker.postMessage({ type: 'PROCESS', data: new Float64Array(1024) });
// Float64Array is cloned: 8 KB copied, ~0.15 ms
worker.onmessage = ({ data }) => {
console.log('Result:', data.result); // fresh copy
};
Simplest to write. Use when payloads are small (< 64 KB), the data shape is complex (Maps, Sets, nested objects), or you do not need the sending side to remain live.
postMessage (transfer)
// main.ts
const buffer = new ArrayBuffer(10 * 1024 * 1024); // 10 MB
const worker = new Worker('./worker.js');
worker.postMessage(buffer, [buffer]); // transfer list
// buffer.byteLength is now 0 — main thread no longer owns it
worker.onmessage = ({ data }) => {
// data is the buffer back, if the worker returned it
const result = new Uint8Array(data as ArrayBuffer);
};
Zero copy, no headers needed. The main thread loses access. Use for large one-shot payloads where ownership moves in one direction. See Transferable Objects & Zero-Copy for the full pattern.
SharedArrayBuffer + Atomics
// main.ts
const sab = new SharedArrayBuffer(10 * 1024 * 1024);
const view = new Int32Array(sab);
const workerA = new Worker('./worker-a.js');
const workerB = new Worker('./worker-b.js');
workerA.postMessage({ sab });
workerB.postMessage({ sab });
// Both workers now have concurrent read/write access to the same 10 MB
Zero copy, concurrent access, sub-millisecond coordination — but you must manage ordering and avoid data races using Atomics. Requires COOP/COEP headers.
The Decision Rubric
Work through these questions in order. Stop as soon as you have an answer.
1. Do you need multiple threads to access the same memory concurrently?
If yes → SharedArrayBuffer. There is no way to do this with postMessage.
2. Are you transferring a large buffer (> 1 MB) exactly once from one thread to another?
If yes → transferable ArrayBuffer via postMessage. Zero copy, no special headers, easy to reason about.
3. Is the payload small (< 64 KB) and complex (nested objects, Maps, Sets)?
If yes → structured clone via postMessage. The copy cost is negligible and the API is the simplest.
4. Is coordination latency on the critical path (< 0.05 ms per signal)?
If yes → SharedArrayBuffer with Atomics.wait / Atomics.notify. This is the only sub-millisecond coordination mechanism available in browser workers.
5. Can you add COOP/COEP headers without breaking third-party iframes or embeds?
If no → rule out SharedArrayBuffer. Use transferable ArrayBuffer ping-pong if you need zero-copy semantics.
6. Is code maintainability and debuggability a priority?
If yes, lean toward postMessage. Shared mutable state is notoriously difficult to debug. DevTools shows postMessage payloads clearly in the Performance panel; shared-memory state changes are invisible.
Summary matrix
| Scenario | Recommended mechanism |
|---|---|
| One-off large payload (> 1 MB), single direction | Transfer (postMessage + transfer list) |
| Small, complex structured data | Structured clone (postMessage) |
| Multiple workers reading / writing the same buffer | SharedArrayBuffer + Atomics |
| Sub-millisecond signalling between workers | SharedArrayBuffer + Atomics.wait/notify |
| Ring buffer / lock-free queue | SharedArrayBuffer + Atomics |
| Real-time audio (AudioWorklet coordination) | SharedArrayBuffer + Atomics |
| Cannot add COOP/COEP headers | Transfer or structured clone |
| Prototyping / simple task offload | Structured clone (postMessage) |
What Hybrid Approaches Look Like
In practice, sophisticated applications use all three mechanisms together:
- Control plane —
postMessagewith structured clone for task dispatch, configuration, and error reporting. These messages are infrequent and the payloads are small. - Data plane —
SharedArrayBufferfor the hot path: a ring buffer or shared frame buffer that workers update continuously. - Large one-shot transfers — transferable
ArrayBufferwhen a worker finishes processing a large result and hands it back to the main thread for rendering.
// Hybrid pattern sketch
const sab = new SharedArrayBuffer(RING_BUFFER_BYTES); // shared data plane
const worker = new Worker('./worker.js');
// Control: send config and the shared buffer reference
worker.postMessage({ type: 'INIT', config: { fps: 60 }, sab });
// Data: worker posts a finished frame back as a transfer
worker.onmessage = ({ data }) => {
if (data.type === 'FRAME') {
// data.buffer is a transferred ArrayBuffer — main thread draws it
renderFrame(data.buffer as ArrayBuffer);
}
};
Browser Support Summary
| Mechanism | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
postMessage structured clone |
All | All | All | All |
postMessage transfer (ArrayBuffer) |
17 | 20 | 5.1 | 12 |
SharedArrayBuffer (COOP/COEP) |
92 | 79 | 15.2 | 92 |
Atomics.waitAsync |
87 | 100 | 16.4 | 87 |
If you need to support Safari before 15.2, SharedArrayBuffer is not available. Fall back to transferable ArrayBuffer ping-pong: the producer fills a buffer, transfers it, the consumer processes it and transfers it back. Latency is higher (~0.2 ms per round-trip vs ~0.01 ms for Atomics) but requires no special headers.
The decision almost always comes down to one question: does your use case require concurrent access to the same memory? If yes, SharedArrayBuffer is the only option. If no, a transferred ArrayBuffer gives you zero-copy performance with far simpler code and no header requirements.