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.

COOP / COEP required for SharedArrayBuffer

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.

The same four slots under all three transports Three stacked panels, each with a main-thread heap on the left and a worker heap on the right. In the first panel, structured clone, both heaps hold a full four-cell buffer and a single arrow between them is labelled serialize, transfer, rebuild; the main thread keeps its own copy and the worker gets an independent one, at a cost that grows with the object graph. In the second panel, transfer, the main-thread cells are drawn as empty dashed outlines labelled detached with byteLength zero, the worker holds the only filled buffer, and the arrow says the memory itself changes hands in under 0.05 milliseconds at any size. In the third panel, shared memory, neither heap holds cells at all: a single four-cell buffer sits between them and both heaps point at it with a short arrow marked view, so a write lands in both views at once and Atomics decides when it becomes visible. The same four slots under all three transports 1 · Structured clone — postMessage(value) main-thread heap worker heap A B C D A B C D keeps its own copy gets an independent copy serialize → transfer → rebuild cost grows with the object graph 2 · Transfer — postMessage(value, [buffer]) main-thread heap worker heap A B C D detached · byteLength 0 sole owner of the bytes the memory itself changes hands under 0.05 ms, whatever the size 3 · Shared memory — one SharedArrayBuffer, mapped twice main-thread heap worker heap Int32Array view Int32Array view no copy · same pages no copy · same pages one allocation, two views A B C D view view a write lands in both views at once — Atomics decides when it is visible Clone and transfer both end in a delivery. Shared memory has none — which is why it is the only one that can race.
Only the third panel leaves the bytes in one place. The first two differ in who pays and who ends up owning the buffer; the third removes the question by giving both agents a view over the same pages.

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

  1. void data.echo?.length inside the timed region is the line that makes the numbers honest. Blink deserializes lazily, on the first read of event.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.
  2. [moved.buffer], not [moved] — the transfer list takes the underlying ArrayBuffer, never the typed-array view. Passing the view throws DataCloneError, which is the single most common first-attempt failure with transfers.
  3. 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.
  4. worker.postMessage({ kind: 'shared', sab }) has an empty transfer list. A SharedArrayBuffer is 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.
  5. Atomics.waitAsync on the main thread, Atomics.wait in the worker. The blocking form throws a TypeError on the main thread by design — it would freeze rendering, input and timers. The async form returns { async, value } where value settles 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.
  6. 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 three harness runs, drawn to the same time axis Three rows share one horizontal axis running from zero to twelve milliseconds. In each row the upper block is main-thread work and the lower block is worker work, joined by a short diagonal queue hop. Run one, structured clone, is a wide serialize block of about 5.6 milliseconds, a 0.3 millisecond hop, and an equally wide rebuild block that only starts when the worker first reads event.data; the whole run spans about 11.5 milliseconds. Run two, transfer, is two hairline blocks about 0.04 milliseconds each with the same hop between them, so the whole run is roughly 0.35 milliseconds wide and almost all of it is the hop. Run three, shared memory, has no payload block at all: a single Atomics.notify to Atomics.wait wake of 10 to 20 microseconds, less than half a pixel on this axis, above a dashed rail marking the buffer that was mapped into both agents at startup and never crosses the boundary. One 8 MB payload, three transports, one time axis in every row the upper block runs on the main thread and the lower block runs in the worker 1 · clone 8 MB copied twice ≈ 11.5 ms main worker serialize — 8 MB, ≈ 5.6 ms rebuild on the first data read — ≈ 5.6 ms queue hop ≈ 0.3 ms 2 · transfer ownership moves ≈ 0.35 ms the whole run is ≈ 0.35 ms — 0.04 ms of it is the buffer, the rest is the hop sender view detached · byteLength 0 · flat cost at any payload size 3 · shared no bytes cross ≈ 0.02 ms Atomics.notify → Atomics.wait: 10–20 µs — under half a pixel on this axis the 8 MB never crosses — both agents already hold views over the same pages one wake, no payload — the buffer was mapped into both agents at startup 0 2 4 6 8 10 12 milliseconds elapsed — the same 8 MB Float32Array through each transport Shared memory does not make the transfer faster — it removes the transfer.
Runs 1 and 2 time an 8 MB payload crossing a boundary; run 3 times a signal. The clone's second block is the half that hides in a later task unless the harness forces the read inside the timed region.

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
The rubric as a ladder: six gates, one veto Six numbered gates are stacked vertically. Each gate carries a question, a yes arrow leaving to the right into an outcome chip, and a no arrow dropping to the next gate. Gate one, do two or more agents need the same bytes at once, exits to SharedArrayBuffer plus Atomics. Gate two, is a large binary payload moving one way once, exits to transfer. Gate three, is the payload small or structurally rich, exits to structured clone. Gate four, must coordination itself beat a message hop, exits to Atomics.wait and Atomics.notify. Gate five is drawn as a full-width barrier rather than a bar: can you serve COOP and COEP without breaking an embed. Its no exit leads to a transferable ping-pong chip, and a dashed rail runs up the right-hand margin from the barrier to the two shared-memory chips, showing that crossOriginIsolated equal to false overrides both of those answers no matter how the earlier questions came out. Gate six, will someone other than you maintain this, exits to prefer messages when it is a tie. Six questions, in the order that eliminates the most options first 1 · Do two or more agents need the same bytes at once? concurrent readers and writers over one region yes SharedArrayBuffer + Atomics 2 · Is a large binary payload moving one way, once? over 1 MB, single direction, exactly one owner yes transfer — postMessage(v, [buf]) 3 · Is it small, or structurally rich? under 100 KB, or nested objects, Map, Set, Date, cycles yes structured clone 4 · Must coordination itself beat a message hop? 10–20 µs futex wake against a 0.1–0.5 ms hop yes Atomics.wait / Atomics.notify no no no no 5 · Can you serve COOP and COEP without breaking an embed? one non-CORP subresource keeps crossOriginIsolated at false if no transferable ping-pong crossOriginIsolated === false overrides every shared-memory answer above yes 6 · Will someone other than you maintain this? a shared-memory race reproduces on one machine, under load yes prefer messages when it is a tie Stop at the first question that answers — but question 5 can overturn everything above it.
The ladder is ordered by how decisively each question eliminates options, not by how often it applies. Gate 5 is drawn as a barrier rather than a step because it is a deployment fact, not a preference: when it fails, the two shared-memory outcomes are unavailable however emphatically gates 1 and 4 said yes.

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 ArrayBuffer handed 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;
  }
};
One worker, three channels, each on the payload it fits A main-thread lane runs across the top and a pipeline-worker lane across the bottom. Three columns stand between them. The left column is the control plane, carried by structured clone: small chips for INIT with config, CANCEL and ERROR with a stack, costing one 0.1 to 0.5 millisecond hop each, small, rare and visible in a profile. Its connector to each lane is a double-headed arrow, because messages travel in both directions. The middle column is the data plane, a SharedArrayBuffer posted once: a ring of frames written and read in place, with an Atomics band marking where the ordering is defined. Its connectors to both lanes are plain bars with no arrowheads, labelled mapped, not sent and no delivery step, because nothing is ever delivered. The right column is the result path, carried by transfer: a decoded-frame ArrayBuffer moves worker to main thread in under 0.05 milliseconds at any size, leaving a dashed empty slot behind labelled sender view byteLength zero. Its arrows point one way only. One worker, three transports — each on the payload it fits Main thread — UI, input, rendering owns the DOM; must never block Pipeline worker — decode, analyse, encode holds views over the shared buffer; posts finished frames back control plane structured clone INIT + config CANCEL ERROR + stack 0.1–0.5 ms per hop small, rare, and profileable data plane SharedArrayBuffer, posted once ring of frames written and read in place Atomics defines the order ≈ 0 — nothing crosses every agent you map it into sees the same bytes result path transfer decoded frame ArrayBuffer zero copy sender view: byteLength 0 under 0.05 ms at any size one owner, one direction mapped, not sent no delivery step The rubric is applied per payload, not per application — which is why all three appear at once.
Three channels, three different answers to the same rubric. Note what the middle column does not have: an arrowhead. Control and result both have a delivery step you can see in a profile; the data plane has none, which is exactly what makes it fast and what makes it your responsibility.

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

Performance

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.notifyAtomics.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
One-way cost per operation, on a logarithmic millisecond axis Five horizontal bars on a logarithmic axis running from 0.01 to 100 milliseconds. An Atomics.notify to wait wake is 0.015 milliseconds; transferring a 10 megabyte ArrayBuffer is 0.04 milliseconds; cloning a 1 kilobyte control message is 0.06 milliseconds; cloning a 1 megabyte Float32Array is 0.7 milliseconds; cloning a 10 megabyte Float32Array is 7 milliseconds. A shaded vertical band from 0.1 to 0.5 milliseconds marks the fixed message-hop floor: the first three bars all end to the left of it, so they are already cheaper than the hop that carries them and optimising them changes nothing. A dashed vertical rule at 16.7 milliseconds marks one frame at 60 frames per second, sitting just beyond the 10 megabyte clone bar — so a single 10 megabyte clone eats most of a frame and a round trip of two exceeds it. What a millisecond buys: one-way transport cost on a log scale orders of magnitude, not benchmarks — measure your own payload before optimising anything message-hop floor 0.1–0.5 ms 16.7 ms — one 60 fps frame Atomics.notify → wait wake 0.015 ms transfer 10 MB ArrayBuffer 0.04 ms clone 1 KB control message 0.06 ms clone 1 MB Float32Array 0.7 ms clone 10 MB Float32Array 7 ms 0.01 0.1 1 10 100 one-way cost, milliseconds (log scale) Everything left of the shaded band is cheaper than the hop that carries it — optimising it changes nothing.
Each decade is the same width, so the gap between a transfer and a 10 MB clone is more than two full decades — and the gap between a transfer and an 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.

Nine years of SharedArrayBuffer across the four engines Four horizontal lanes, one per engine, over a 2017 to 2026 axis. All four ship SharedArrayBuffer during 2017 without any isolation requirement, and all four lanes break in January 2018 when Spectre forces every engine to switch it off. Chrome returns in mid-2018 on desktop only, then requires cross-origin isolation from Chrome 92 in July 2021. Firefox returns in July 2020 already gated on isolation. Edge picks it up with the Chromium switch in early 2020 and follows Chrome to mandatory isolation. Safari is last, returning in 15.2 in December 2021. A dashed vertical line at December 2021 marks the first date on which all four engines are simultaneously live, which is the earliest point a shared-memory feature can be targeted without a fallback. A final marker in March 2023 records Safari 16.4 adding Atomics.waitAsync. Nine years of SharedArrayBuffer: shipped, withdrawn, returned behind a header one lane per engine — where a lane is empty, the constructor did not exist Jan 2018 switched off everywhere Dec 2021 all four engines live again Chrome Chrome 68–91 · desktop only Chrome 92+ · COOP + COEP Firefox Firefox 79+ · COOP + COEP Safari Safari 15.2+ · COOP + COEP Edge Edge 79–91 Edge 92+ · COOP + COEP 1 2 3 4 5 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026 1 · Jan 2018 — Spectre: every engine disables SharedArrayBuffer 4 · Dec 2021 — Safari 15.2 returns; all four engines live 2 · Jul 2020 — Firefox 79 returns, isolation-gated 5 · Mar 2023 — Safari 16.4 adds Atomics.waitAsync 3 · Jul 2021 — Chrome & Edge 92 make isolation mandatory available without isolation available only when cross-origin isolated a gap in the lane = not available at all
The dashed December 2021 line is the practical floor for shipping shared memory without a fallback path — and because the return was conditional rather than unconditional, everything to the right of it still depends on your own response headers rather than on the browser alone.

Frequently Asked Questions

Is SharedArrayBuffer always faster than postMessage?
No — for a one-way handoff it is usually a tie, and sometimes a loss. A transferable ArrayBuffer moves ownership in under 0.05 ms regardless of size, so a 50 MB buffer sent once costs the same as a 50 KB one and needs no isolation headers. Shared memory wins on two axes only: several agents reading and writing the same bytes at the same time, and coordination latency — an Atomics.notifyAtomics.wait wake is roughly 10–20 µs against a 0.1–0.5 ms floor for a message hop. If neither of those is on your critical path, SharedArrayBuffer buys you nothing and costs you a memory model you now have to enforce by hand.
Can I use SharedArrayBuffer in a Shared Worker or a Service Worker?
Yes, with the same precondition as anywhere else: the client that creates the buffer must be cross-origin isolated, and the worker inherits that isolation from the context that spawned it. Both worker kinds can receive a SharedArrayBuffer through postMessage (never in the transfer list — it is shared, not transferred) and hold typed-array views over it exactly like a dedicated worker. The practical caveat is lifetime rather than capability: a Service Worker can be terminated between events at the browser’s discretion, so it must never be the agent holding a lock or parked in Atomics.wait, or the buffer’s other users stall behind an agent that no longer exists.

See also