SharedArrayBuffer & Atomics

Every other cross-thread mechanism on the platform is a message: you hand bytes to the runtime, it delivers a copy or moves ownership, and the receiver reacts on its own event loop turn. SharedArrayBuffer is the one exception — it maps the same physical memory pages into several agents at once, so a write in one worker is a write in all of them, with no delivery step at all. That removes the copy and the queue, and in exchange it hands you the two problems every systems programmer inherits: visibility and ordering. This guide is part of the Web Workers Architecture & Communication reference, and it covers the shared-memory path end to end — from isolation headers through futex coordination to the failure modes that only surface under real concurrency.

The Atomics namespace is what makes shared memory tractable. It is not a convenience wrapper around array indexing; it is the only part of the JavaScript memory model that carries ordering guarantees. Understanding which operations are ordered — and which are silently free to be reordered by the CPU and the JIT — is the whole discipline.


Why postMessage Hits a Floor

Consider a fluid-simulation view: 240,000 particles integrated across two workers, composited by the main thread at 60 fps. Each frame the workers need the previous positions and the main thread needs the new ones. Built on postMessage with structured clone, one frame costs a 3.8 MB Float32Array cloned out of each worker and into the page — roughly 5–7 ms of pure copy per hop on a 2023 laptop, on top of a 0.1–0.5 ms delivery latency in each direction. The 16.7 ms frame budget is gone before any drawing happens, and the profile shows a very specific signature:

  • The workers’ own integration time is stable at ~3 ms; neither worker is the bottleneck.
  • Long tasks on the main thread line up exactly with message arrivals, not with rendering.
  • Garbage-collection pauses grow with frame rate, because every frame allocates and discards two multi-megabyte buffers.
  • Raising the worker count makes it worse — more agents means more clones of the same data.

Transferring the buffer instead of cloning it removes the copy, but transfer moves ownership: the sender’s view is neutered the moment postMessage returns, so the two workers cannot both hold the field, and a shared read-mostly dataset has to ping-pong. That is the wall shared memory exists to break. With a SharedArrayBuffer the particle field is allocated once, viewed by all three agents, and the per-frame cross-thread traffic collapses from megabytes to a handful of 32-bit control words. Where the copy itself is the thing you want to measure first, postMessage Bottleneck Analysis covers the instrumentation.

Per-frame cost of the same particle field under clone, transfer and shared memory Three stacked bars measured against the 16.7 millisecond frame budget. With postMessage and structured clone the frame costs 18.3 milliseconds: 3 milliseconds of integration, 12.4 milliseconds of cloning across both hops, delivery latency and 2 milliseconds of compositing — over budget. With transfer the frame costs 10.1 milliseconds, because the buffer has exactly one owner and the second worker spends 5.1 milliseconds waiting for it to come back. With a SharedArrayBuffer the frame costs 5.2 milliseconds: integration, a few microseconds of atomic control words, and compositing, leaving 11.5 milliseconds of headroom. One 60 fps frame with a 3.8 MB particle field and two workers 16.7 ms — 60 fps budget postMessage + structured clone integrate 3.0 structured clone, both hops — 12.4 ms 18.3 ms — over budget postMessage + transfer integrate 3.0 waits for the buffer 5.1 10.1 ms — one worker idles SharedArrayBuffer + Atomics integrate 3.0 5.2 ms — 11.5 ms of headroom 0 5 10 15 20 ms worker integration structured-clone copy delivery latency + ownership stall main-thread composite
The copy is not a tax on the transport — it is the frame. Only the third bar leaves room for the drawing the frame exists to do, and the thin pumpkin sliver in it is the entire cross-thread traffic: a few 32-bit control words.
COOP / COEP headers are mandatory

Browsers disabled SharedArrayBuffer after the Spectre disclosure in 2018, because shared memory plus a counter thread reconstructs a high-resolution timer precise enough to mount a cache side-channel attack. It came back in 2020 only for pages that are cross-origin isolated. Your server must send both headers on the document response:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Every cross-origin subresource must then be CORP- or CORS-eligible, or isolation silently fails and typeof SharedArrayBuffer is "undefined" everywhere on the page — including inside workers, which inherit isolation from their owner document. See Debugging SharedArrayBuffer Cross-Origin Errors for the diagnostic sequence.


Prerequisites

Before implementing any of the steps below, confirm the following:

  • crossOriginIsolated === true in the page and in every worker. Check it at runtime rather than trusting the deployment: a CDN that strips headers on one route is the most common cause of a feature that works locally and vanishes in production.
  • Baseline browser support: Chrome 92+, Firefox 79+, Safari 15.2+. Safari shipped the Atomics object years before it shipped a usable SharedArrayBuffer, so feature-detect the constructor, not the namespace.
  • Module workers (new Worker(url, { type: 'module' })) so both sides can import the same slot-layout constants. Classic workers work too, but then the layout has to be duplicated by hand — a guaranteed source of off-by-one corruption.
  • A working postMessage protocol for everything that is not hot-path data. Shared memory carries state; setup, teardown and errors still travel as messages, and Message Passing Strategies covers that envelope design.
  • A load generator that oversubscribes the design by 3–5×. Races, lost wakeups and false sharing are invisible at low contention and reproducible at high contention.
  • Worker threads visible in your profiler, so you can tell a thread parked in Atomics.wait (correct, cheap) from a thread spinning in a CAS loop (correct, expensive).

Step 1 — Allocate the Buffer and Fan It Out

Allocate on the main thread and distribute by postMessage. Unlike an ArrayBuffer, a SharedArrayBuffer is not transferable and must never appear in the transfer list — passing it there throws a DataCloneError. Posting it normally maps the same pages into the receiving agent.

// main.ts
import { HEADER_SLOTS, PAYLOAD_FLOATS } from './layout';

// Int32 header + Float32 payload, both 4 bytes per element.
const BYTES = (HEADER_SLOTS + PAYLOAD_FLOATS) * 4;
const sab = new SharedArrayBuffer(BYTES);

const workers = [0, 1].map(
  (i) => new Worker(new URL('./integrator.ts', import.meta.url), { type: 'module' })
);

// No transfer list: every worker ends up mapped to the SAME physical pages.
for (const [index, worker] of workers.entries()) {
  worker.postMessage({ kind: 'INIT', sab, index, workerCount: workers.length });
}

The cost of that fan-out does not scale with buffer size. Structured clone is O(bytes); handing over a shared mapping is O(1), because the browser duplicates a page-table reference rather than the pages themselves. A 512 MB shared buffer posts as fast as a 4 KB one.

Performance

Posting a SharedArrayBuffer completes in under 0.05 ms regardless of size. Cloning the equivalent ArrayBuffer costs roughly 12–18 ms per 10 MB and allocates that much again on the receiving heap, so the same data resident in three agents costs three times the memory.

Fanning a buffer out to two workers: shared mapping versus structured clone On the left, the main thread allocates one SharedArrayBuffer of 3.8 megabytes and posts it to two workers; each worker builds its own views over the same physical pages, so the fan-out is a constant-time page-table mapping under 0.05 milliseconds regardless of size. On the right, the same data sent as a plain ArrayBuffer is structure-cloned once per hop, costing about 6 milliseconds and a fresh 3.8 megabyte allocation per worker, for 11.4 megabytes resident across three heaps. Fanning one buffer out to two workers Shared memory — one allocation Structured clone — one per agent Main thread new SharedArrayBuffer(3.8 MB) 3.8 MB of shared pages one allocation, three mappings Worker 0 own views, same bytes Worker 1 own views, same bytes postMessage(sab) — O(1), under 0.05 ms at any size Main thread new ArrayBuffer(3.8 MB) structured clone per hop ≈ 6 ms and a fresh 3.8 MB each Worker 0 its own 3.8 MB copy Worker 1 its own 3.8 MB copy 11.4 MB resident — and the cost grows with the buffer A SharedArrayBuffer must never appear in the transfer list — posting it there throws DataCloneError.
The two fan-outs differ in complexity class, not in constant factor: the left-hand cost is one page-table reference per agent, the right-hand cost is proportional to the bytes and is paid again on every hop.

Step 2 — Give the Buffer an Explicit Layout

The single biggest source of shared-memory bugs in browser code is ad-hoc slot indices scattered across files. Treat the buffer like a struct: one module owns the layout, both threads import it, and nothing addresses a raw index by hand. Separate the control header (small, hot, atomically accessed) from the payload (large, mostly touched by plain reads and writes under a protocol that guarantees exclusivity).

// layout.ts — imported by main thread and every worker
export const enum Slot {
  Lock = 0,        // 0 = free, 1 = held
  Generation = 1,  // bumped by the producer, waited on by consumers
  ReadyCount = 2,  // workers that have finished the current frame
  Epoch = 3,       // futex slot for wait/notify
}

export const HEADER_SLOTS = 16;              // 16 × 4 B = 64 B = one cache line
export const PAYLOAD_FLOATS = 240_000 * 4;   // x, y, vx, vy per particle

export interface SharedViews {
  header: Int32Array;
  payload: Float32Array;
}

export function mapViews(sab: SharedArrayBuffer): SharedViews {
  return {
    header: new Int32Array(sab, 0, HEADER_SLOTS),
    payload: new Float32Array(sab, HEADER_SLOTS * 4, PAYLOAD_FLOATS),
  };
}

Two rules make this layout safe. First, byte offsets must be aligned to the element size of the view — a Float64Array starting at byte 4 throws a RangeError at construction, and the failure is immediate rather than subtle. Second, Atomics.wait and Atomics.notify only accept Int32Array or BigInt64Array, so any slot you intend to park on must live in the integer header, never in a float payload.

Views are per-agent, bytes are not

Each agent constructs its own Int32Array over the buffer. Those view objects are independent JavaScript objects with independent byteOffset values — only the memory underneath is shared. Never post a typed array and expect the other side to reuse the view: post the SharedArrayBuffer and rebuild views locally through the same mapViews helper.

Byte map of the shared buffer: a 64-byte Int32 header followed by the Float32 payload The first sixteen Int32 slots form the control header: slot 0 is the Lock, slot 1 the Generation counter, slot 2 the ReadyCount barrier, slot 3 the Epoch futex slot, and slots 4 to 15 are padding so the hot control words occupy exactly one 64-byte cache line. Everything after byte 64 is the Float32 payload, split into two halves — worker 0 writes particles 0 to 119,999 and worker 1 writes particles 120,000 to 239,999 — with four floats per particle. One buffer, two regions: a 64-byte control header, then the payload header — new Int32Array(sab, 0, 16) slots 4–15: padding, keeping the hot slots on one line Lock Gen Ready Epoch 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 slots × 4 B = 64 B — the whole header is one cache line payload — new Float32Array(sab, 64, 960 000) worker 0 writes particles 0…119 999 x, y, vx, vy — four floats each worker 1 writes particles 120 000…239 999 x, y, vx, vy — four floats each Atomics.wait and notify accept only Int32Array or BigInt64Array — park slots live in the header. Byte offsets must be a multiple of the view's element size, or the constructor throws RangeError.
The layout module is the single source of truth for both regions. Padding slots 4–15 are not waste: they keep the four hot control words on a line of their own, which is what Step 6 relies on.

Step 3 — Read and Write Control State Through Atomics

Never touch a shared control slot with plain index syntax. Atomics.load and Atomics.store perform sequentially-consistent accesses: every atomic operation in the program participates in a single total order that all agents observe identically, and no agent can see them out of that order.

// integrator.ts (producer side)
import { Slot } from './layout';

function publishFrame(header: Int32Array, payload: Float32Array, positions: Float32Array): void {
  payload.set(positions);                        // plain writes — fast, unordered on their own
  Atomics.store(header, Slot.Generation, frame); // release: everything above is now visible
}
// main.ts (consumer side)
function tryConsume(header: Int32Array, lastSeen: number): number | null {
  const gen = Atomics.load(header, Slot.Generation); // acquire
  if (gen === lastSeen) return null;                 // nothing new this tick
  // Every plain write the producer made before its Atomics.store is visible here.
  return gen;
}

The mental model worth internalising: an atomic operation is a fence for the plain accesses around it. Plain writes issued before an Atomics.store cannot be reordered after it, and plain reads issued after an Atomics.load cannot be hoisted before it. That is what makes the generation-counter pattern above correct — the counter is atomic, the bulk payload is not, and the atomic access carries the payload’s visibility with it. Drop the Atomics call and the JIT is free to keep those payload writes in registers indefinitely; the consumer may read stale bytes forever, and no amount of retrying will fix it.

Performance

An uncontended Atomics.load or Atomics.store on a warm cache line costs a few nanoseconds; on a cold line it is one cache-coherence round-trip, roughly 10–30 ns on x86 and slightly less on Apple silicon. That is around four orders of magnitude cheaper than a postMessage round-trip, which is why control words can be polled per frame while messages cannot.

The happens-before edge created by Atomics.store and Atomics.load, and what is lost without it In the upper pair of lanes the producer worker performs plain payload writes and then an Atomics.store on the generation slot; the consumer on the main thread performs an Atomics.load of the same slot and is then guaranteed to read the new payload, because the store-load pair creates a happens-before edge covering the shaded region. In the lower pair the same plain writes are made with no atomic operation between them: no ordering edge exists, the writes may stay in registers or a store buffer, and the consumer reading with plain indexing may see stale bytes indefinitely. Why the fence matters: what the consumer is guaranteed to see Worker (producer) Main thread (consumer) plain writes — payload.set(…) Atomics.store(Generation, n) Atomics.load(Generation) reads the new payload happens-before Everything written before the store is visible to any agent that observes it with a load. Worker — no fence Main thread — plain read payload writes may stay in registers view[i] — may read stale bytes forever no ordering edge An atomic access is a fence for the plain accesses around it — that is the whole guarantee.
The shaded band is the only region the memory model says anything about. Remove the store and the load, and the two lanes below are what remains: correct-looking code with no guarantee that a single byte ever crosses.

Step 4 — Build Lock-Free Primitives with add and compareExchange

Atomics.add is a read-modify-write executed as one indivisible step; it returns the value before the addition. That single property is enough to build barriers, reference counts and ring-buffer indices without any lock at all.

// A frame barrier: the last worker to arrive wakes the compositor.
function signalFrameDone(header: Int32Array, workerCount: number): void {
  const previous = Atomics.add(header, Slot.ReadyCount, 1);
  if (previous === workerCount - 1) {
    // We are the last worker in; reset and publish.
    Atomics.store(header, Slot.ReadyCount, 0);
    Atomics.add(header, Slot.Epoch, 1);
    Atomics.notify(header, Slot.Epoch);
  }
}

Atomics.compareExchange(view, index, expected, replacement) is the primitive under every lock-free algorithm: it writes replacement only if the slot currently holds expected, and returns whatever it found. A failed swap tells you another agent got there first, which is the signal to re-read and retry.

// Bounded-spin mutex: cheap under low contention, futex-backed under high contention.
const FREE = 0;
const HELD = 1;
const SPIN_LIMIT = 64; // ~1–2 µs of retries before we stop burning CPU

export function acquire(header: Int32Array, slot: number): void {
  for (let i = 0; i < SPIN_LIMIT; i++) {
    if (Atomics.compareExchange(header, slot, FREE, HELD) === FREE) return;
  }
  // Contended: stop spinning and sleep until the holder releases.
  while (Atomics.compareExchange(header, slot, FREE, HELD) !== FREE) {
    Atomics.wait(header, slot, HELD); // returns "ok" | "not-equal"
  }
}

export function release(header: Int32Array, slot: number): void {
  Atomics.store(header, slot, FREE);
  Atomics.notify(header, slot, 1); // wake exactly one contender
}

Note the shape of the contended path: the CAS is re-attempted before every wait, and wait is given the value that means “still locked”. If the holder released in the window between the failed swap and the wait call, the slot no longer holds HELD, Atomics.wait returns "not-equal" immediately, and the loop retries instead of sleeping forever. That check-then-park ordering is not optional — it is the entire defence against lost wakeups.

Unbounded spinning does not scale down

A pure spin-lock assumes the holder is running on another core right now. In a browser that assumption fails constantly: the tab may be throttled in the background, the machine may have fewer cores than you have workers, and a spinning worker prevents nothing else from progressing while it burns a full core. Always bound the spin and fall back to Atomics.wait.

State machine of the bounded-spin mutex, from CAS attempt to parked waiter An agent calls acquire and attempts a compareExchange from FREE to HELD. If the swap succeeds it holds the lock and runs its critical section inside try/finally, then release stores FREE and notifies one waiter. If the swap fails the agent spins, retrying the compareExchange up to sixty-four times; a later retry may win and take the lock. Once the spin limit is reached the agent parks in Atomics.wait on the same slot with the expected value HELD, and the notify issued by release wakes it so it can retry the swap. The bounded-spin mutex as a state machine acquire() compareExchange(FREE, HELD) Held critical section runs in try/finally Spinning retry the CAS, bounded at 64 Parked Atomics.wait(header, slot, HELD) release() store FREE, then notify(1) compareExchange(FREE, HELD) succeeds swap fails a later retry wins spin limit reached critical section ends notify → CAS retried notify(1) Atomics.wait re-checks the slot as it parks: if the holder released in that window it returns "not-equal" and the loop retries.
Only two edges leave the spinning state, and both are bounded: a win, or a park. The edge that does not exist — spinning forever — is what makes this safe on a throttled background tab with fewer cores than workers.

Step 5 — Park and Wake Threads with wait, waitAsync and notify

Atomics.wait(view, index, expected, timeout?) compares the slot against expected and, only if they match, parks the calling agent until another agent calls Atomics.notify on the same slot or the optional millisecond timeout elapses. It is a direct analogue of the POSIX futex call, and it returns one of three strings: "ok" (woken by a notify), "not-equal" (the value had already changed, so no parking happened) or "timed-out".

// integrator.ts — worker sleeps until the compositor bumps the epoch
function awaitNextFrame(header: Int32Array, lastEpoch: number): number {
  const result = Atomics.wait(header, Slot.Epoch, lastEpoch, 250);
  if (result === 'timed-out') {
    self.postMessage({ kind: 'STALL', lastEpoch }); // supervisor decides what to do
  }
  return Atomics.load(header, Slot.Epoch);
}

The main thread cannot use that API, and the restriction is deliberate: parking the main agent would stop rendering, input and timers with no way back. Atomics.waitAsync is the non-blocking form — it returns { async: boolean, value: string | Promise<string> }, where async is false when the wait resolved synchronously (the value had already changed) and true when you were handed a real Promise.

// main.ts — non-blocking wait, safe on the main thread
async function awaitEpoch(header: Int32Array, expected: number): Promise<string> {
  const { async: parked, value } = Atomics.waitAsync(header, Slot.Epoch, expected, 1000);
  return parked ? await (value as Promise<string>) : (value as string);
}

Atomics.notify(view, index, count?) wakes at most count waiters (all of them when omitted) and returns how many it actually woke — a return of 0 means nobody was parked, which is normal and not an error. Wake exactly one waiter for a mutex handoff; wake all of them for a broadcast such as “the epoch advanced” or “shutdown requested”.

Atomics.wait throws on the main thread

Calling Atomics.wait from the main agent throws TypeError: Atomics.wait cannot be called in this context. The same code is legal inside a worker, so a helper shared by both threads must branch on context rather than assume — check typeof WorkerGlobalScope !== 'undefined' and route the main thread to Atomics.waitAsync.

Two workers parked on the epoch slot, woken by one notify from the compositor Worker A and worker B each call Atomics.wait on the epoch slot with the current epoch as the expected value, and park. The main thread compositor stores epoch plus one and then calls Atomics.notify on the same slot; both waiters wake, their wait calls return the string ok, and each resumes at the new epoch. Below, the opposite interleaving: the compositor stores and notifies before the worker calls wait, so the value no longer matches the expected epoch, wait returns not-equal without parking, and the loop retries instead of sleeping forever. Parking on the epoch slot, and the interleaving that never parks Worker A Main thread (compositor) Worker B Atomics.wait(Epoch, e) — parked Atomics.wait(Epoch, e) — parked Atomics.store(Epoch, e+1) Atomics.notify(header, Epoch) wakes — returns "ok" wakes — returns "ok" resumes at epoch e+1 resumes at epoch e+1 If notify lands first: store(e+1) + notify worker calls wait(…, e) returns "not-equal" — retries
One notify serves every parked agent, and the value check inside wait is what makes the lower interleaving harmless: the epoch has already moved, so the worker never parks on a condition that has passed.

Step 6 — Memory Ordering, Happens-Before and False Sharing

The ECMAScript memory model gives sequential consistency to atomic accesses and leaves everything else unordered. Three consequences matter in practice.

Atomics order other atomics, and fence the plain accesses around them. There is a total order over all Atomics.* operations that every agent agrees on. Plain accesses have no such order of their own — they inherit visibility only by sitting on the correct side of an atomic operation, which is why the publish/consume pair in Step 3 is written the way it is.

notify and wait establish a happens-before edge. Everything the notifying agent wrote before calling Atomics.notify is visible to the woken agent after Atomics.wait returns "ok". This is the guarantee that lets a worker fill a payload region and wake a consumer without any further synchronisation.

Racy plain accesses are not undefined behaviour, but they are useless. Unlike C++, JavaScript will not miscompile your program because of a data race — a racing read yields some value that was written to that location, never garbage. It simply gives you no guarantee about which value or when, so a protocol built on unsynchronised reads is unfalsifiable rather than merely slow.

The fourth consideration is hardware rather than spec: false sharing. Cache coherence operates on lines, typically 64 bytes, not on individual slots. Two workers hammering header[4] and header[5] are hammering the same line, and every write invalidates the other core’s copy even though the values are unrelated. Pad per-worker counters onto separate lines — with an Int32Array, that means one counter every 16 slots.

// Per-worker counters, one full cache line apart.
const SLOTS_PER_LINE = 16;                       // 64 bytes / 4 bytes per Int32
const counterSlot = (workerIndex: number): number =>
  HEADER_SLOTS + workerIndex * SLOTS_PER_LINE;

Atomics.add(header, counterSlot(myIndex), 1);    // no line shared with any peer
False sharing cost

Four workers incrementing four adjacent Int32 slots on one cache line typically run 3–5× slower than the same workload with counters padded onto separate lines, and the gap widens with core count. The symptom is throughput that gets worse as you add workers — the clearest fingerprint of coherence traffic rather than compute.

False sharing: four counters on one cache line versus one counter per line On the left, counters c0 to c3 occupy four adjacent Int32 slots inside a single 64-byte cache line. Core 0 increments c0 and core 1 increments c2, but because coherence works on whole lines every increment invalidates the other core's copy, which typically runs three to five times slower and gets worse as workers are added. On the right the same counters are padded sixteen Int32 slots apart so each owns its own cache line, the invalidation traffic disappears, and throughput scales with core count. Four counters on one cache line, and the padded layout that fixes it packed — one shared line padded — one line each Core 0 Core 1 one 64-byte cache line c0 c1 c2 c3 every increment invalidates the other core's copy 3–5× slower throughput falls as workers are added Core 0 Core 1 c0 padding c1 padding no shared line — no coherence traffic scales with cores counters 16 Int32 slots apart counterSlot(i) = HEADER_SLOTS + i × 16 — 64 bytes apart, so no two workers ever share a line.
Nothing in the left-hand layout is logically shared: the four counters are unrelated. The hardware does not know that, and the coherence protocol charges you for the line, not for the slot.

What Each Agent Actually Holds

It is worth being precise about the object graph, because the mental picture drives most design mistakes. There is exactly one allocation of physical memory. Every agent holds its own SharedArrayBuffer object referring to it, and its own typed-array views over that object. The views carry per-agent state — byte offset, length, prototype — while the bytes are common. Nothing is copied when a view is created, and nothing is synchronised when a view is discarded.

SharedArrayBuffer shared between Worker A and Worker B Worker A and Worker B each hold a typed-array view over the same SharedArrayBuffer. Atomics operations on either side write directly into the shared physical memory without copying. Worker A Int32Array view Atomics.store / .notify isolated JS heap Worker B Int32Array view Atomics.load / .wait isolated JS heap SharedArrayBuffer shared physical pages slot 0 slot 1 slot 2 Main thread new SharedArrayBuffer(n) No copy — all views access the same bytes. Atomics enforce ordering. Requires Cross-Origin-Opener-Policy + Cross-Origin-Embedder-Policy
All typed-array views over the same SharedArrayBuffer share physical memory. There is no copy on access — atomic operations provide the ordering guarantees that make this safe.

Two lifecycle details follow from that picture. Shared memory is reclaimed only when the last agent holding a reference is gone, so a worker that is never terminated keeps a multi-megabyte buffer alive indefinitely — see Main Thread vs Worker Thread Lifecycle for teardown that actually releases. And modern engines support growable shared buffers via new SharedArrayBuffer(initial, { maxByteLength }) plus sab.grow(n) (Chrome 111+, Firefox 128+, Safari 16.4+), which lets a pool expand without re-posting a new buffer; growth is one-way and views over the buffer track the new length automatically.

WebAssembly threads use exactly this machinery — a WASM shared memory is a SharedArrayBuffer exposed as memory.buffer, addressed by the same Atomics calls from JavaScript. Sharing WebAssembly Linear Memory Across Workers covers that boundary.


Choosing Between Clone, Transfer and Shared Memory

Every cross-thread payload has exactly three transports available, and the right one falls out of two questions: does the data have one owner or several, and does the receiver need it once or continuously?

Transport Mechanism Copy cost Ownership Ordering Best for
Structured clone postMessage(obj) Full copy (~12–18 ms per 10 MB) Independent copies Per-message, implicit Control messages, object graphs, results
Transfer postMessage(buf, [buf]) Zero — pointer moves Exactly one owner; sender neutered Per-message, implicit Large one-shot buffers, producer→consumer pipelines
Shared memory SharedArrayBuffer + Atomics None — same pages All agents, concurrently Yours to enforce Ring buffers, shared state, sub-millisecond signalling

Reach for Transferable Objects & Zero-Copy whenever the data flows one way and one agent at a time — it is zero-copy too, needs no isolation headers, and cannot race by construction. Reach for shared memory when several agents genuinely read and write the same region, or when the coordination itself must be faster than a message hop. Keep structured clone for everything small: lifecycle, configuration, errors and results are far easier to debug as messages than as bytes in a buffer, and mixing the two is the normal production design rather than a compromise. postMessage vs SharedArrayBuffer: When to Choose Each works through the decision case by case.

Decision tree for choosing between structured clone, transfer and shared memory Starting from a payload about to move, ask whether several agents touch the same bytes. If yes, the shared-memory branch is gated on crossOriginIsolated being true: when it is, use a SharedArrayBuffer with Atomics; when it is not, fall back to transferable objects. If only one agent touches the bytes, ask whether the sender is done with them: if it is, transfer them zero-copy with a single owner; if it still needs its own copy, structure-clone the payload, which is the right default for control messages, configuration and results. Choosing a transport for each class of payload a payload to move several agents touch the same bytes? crossOriginIsolated === true? is the sender done with the bytes? yes — several agents no — one owner at a time yes no — not isolated yes — sender is done no — you still need a copy SharedArrayBuffer + Atomics ordering rules Transferable objects zero-copy, one owner Structured clone control, config, results Most systems land on all three at once — the question is asked per message class, not per application.
The isolation gate belongs in the tree, not in a footnote: a design that reaches the shared-memory leaf without a crossOriginIsolated fallback has no behaviour at all on a page that loses its headers.

Lock-Free or Futex-Based: Picking the Coordination Style

Both styles are built from the same primitives; they differ in what happens when two agents want the same slot at the same time.

Lock-free designs use Atomics.compareExchange and Atomics.add so that at least one agent always makes progress, no matter when any other agent is descheduled. Nothing can be “held” and therefore nothing can be stuck holding it. The cost is that every algorithm must be expressible as a retry loop over a single atomic word, which constrains you to one-word state transitions. Single-producer/single-consumer queues, counters, generation flags and index pairs all fit — the Building a Lock-Free Ring Buffer with Atomics walkthrough is the canonical worked example.

Futex-based designs use Atomics.wait and Atomics.notify to build mutexes, semaphores and barriers. They handle multi-slot critical sections that no CAS loop can express, and a parked thread costs nothing while it sleeps. The risk is the classic one: if the holder dies — a top-level throw, an OOM kill, a terminate() at the wrong moment — the lock is never released and every waiter sleeps forever. Always pass a timeout to Atomics.wait in production code and treat "timed-out" as a supervisor event, not as a retry. Coordinating Workers with Atomics.wait and notify covers those patterns in depth, and Worker Pool Management covers the restart policy the supervisor needs.

In practice most systems use both: lock-free counters and generation flags on the hot path, one futex slot for “there is work” and “we are shutting down”.

Lock-free and futex coordination compared across five properties Five rows compare the two styles. Progress guarantee: lock-free means someone always makes progress, futex means the holder must run to release. Cost while idle: a spinning worker burns a core, a parked thread costs nothing. Expressible state: lock-free handles one 32-bit word per transition, futex handles multi-slot critical sections. If an agent dies: with lock-free nothing is held so nothing leaks, with futex waiters sleep until a timeout. Debugging: retry loops hide subtle races, while timeouts turn stalls into observable events. The recommended hybrid keeps lock-free counters on the hot path and one futex slot for work-available and shutdown signals. Lock-free and futex coordination, side by side Lock-free — CAS and add Futex — wait and notify Progress guarantee Cost while idle Expressible state If an agent dies Debugging someone always makes progress the holder must run to release a spinning worker burns a core a parked thread costs nothing one 32-bit word per transition multi-slot critical sections nothing is held, nothing leaks waiters sleep until a timeout retry loops hide subtle races timeouts turn stalls into events In practice: lock-free counters and flags on the hot path, one futex slot for "work available" and "shutting down".
The rows that decide most designs are the last two. Lock-free code cannot leak a lock, but it can hide a race for months; a futex design fails loudly the moment you give every wait a timeout.

Verification & Measurement

Shared-memory bugs do not announce themselves, so verification has to be deliberate.

Assert isolation at startup, in both agents. The failure mode without this check is a TypeError deep in unrelated code, hours after deployment.

export function assertSharedMemoryAvailable(): void {
  if (typeof crossOriginIsolated !== 'undefined' && !crossOriginIsolated) {
    throw new Error('Not cross-origin isolated: set COOP: same-origin and COEP: require-corp.');
  }
  if (typeof SharedArrayBuffer === 'undefined') {
    throw new Error('SharedArrayBuffer unavailable in this context.');
  }
}

Measure the wake latency you actually get, not the one you assumed. Each agent has its own performance.timeOrigin, so raw performance.now() values cannot be subtracted across threads — write both endpoints from the same agent, or exchange time origins once at startup and correct for the delta.

// main.ts — measure a full waitAsync → notify → resume cycle from one clock
const header = new Int32Array(sab, 0, HEADER_SLOTS);
const samples: number[] = [];

for (let i = 0; i < 1_000; i++) {
  const started = performance.now();
  const pending = awaitEpoch(header, Atomics.load(header, Slot.Epoch));
  worker.postMessage({ kind: 'PING' });   // worker bumps Epoch and notifies
  await pending;
  samples.push(performance.now() - started);
}

samples.sort((a, b) => a - b);
console.log('p50', samples[500].toFixed(3), 'p99', samples[990].toFixed(3), 'ms');

Typical results on a 2023 laptop: 0.005–0.02 ms for a worker-to-worker notify/wait handoff versus 0.1–0.5 ms for a postMessage round-trip, with the gap widening sharply once the receiving thread’s task queue has anything else in it. Report percentiles rather than means — the p99 is where scheduler interference and coherence stalls show up, and it is the number that decides whether an audio or animation deadline is met.

Watch for the anti-scaling fingerprint. Record throughput at 1, 2, 4 and 8 workers. Healthy shared-memory code scales sublinearly; code with false sharing or an over-contended lock gets absolutely slower past some worker count. That curve is the fastest diagnostic you have, and it costs one afternoon to produce.

Throughput against worker count for three shared-memory designs Relative throughput measured at one, two, four and eight workers, normalised so one worker equals 1.0. Padded lock-free counters rise steadily to about 5.8 times at eight workers. False-shared counters peak just above 1.4 at two workers and then decline below the single-worker figure by eight. A single global mutex is flat from one worker onward, hovering around 0.7 whatever the worker count. Throughput as the worker count doubles relative throughput (1 worker = 1.0) 0 1 2 4 8 worker threads padded lock-free 5.8× at 8 workers false-shared peaks at 2, then falls one global mutex flat from one worker on Healthy shared-memory code scales sublinearly; anti-scaling is the fingerprint of false sharing or an over-contended lock.
Three designs, one benchmark. Only the shape of the curve distinguishes them — the absolute numbers at a single worker count are identical enough to hide both defects.

Failure Modes & Error Handling

SharedArrayBuffer is not defined. Isolation is off. Check crossOriginIsolated in the console; if it is false while both headers are present, a cross-origin subresource is missing Cross-Origin-Resource-Policy or a crossorigin attribute, and that one asset is disabling the feature for the whole document.

DataCloneError when posting the buffer. You put the SharedArrayBuffer in the transfer list. Shared buffers are cloned by reference and must be passed as ordinary message data, with no second argument.

TypeError: Atomics.wait cannot be called in this context. Main-thread code reached a worker-only path. Branch on context and use Atomics.waitAsync.

RangeError constructing a view. The byte offset is not a multiple of the element size, or offset plus length exceeds the buffer. Both come from hand-computed indices; centralising the layout as in Step 2 eliminates the class.

Lost wakeups. A notify fires while the intended waiter is between its condition check and its wait call, and the waiter then sleeps on a condition that has already passed. The defence is structural: Atomics.wait re-checks the expected value atomically as part of parking, so always pass the value that means “keep waiting” and always loop, never park on a bare true.

Deadlock and livelock. Two agents each parked on a slot the other is supposed to advance never recover on their own. Give every production wait a timeout, escalate "timed-out" to the supervising thread over postMessage, and have the supervisor terminate and rebuild the worker rather than retry blindly. Because shared memory outlives the agent, a restarted worker can re-map the same buffer and resume from the generation counter.

Torn or stale reads. Any code path still reading a shared control slot with view[i] while another agent writes it atomically has no visibility guarantee. Audit for bare indexing on control slots; a lint rule that forbids member access on the header view is cheap and catches every regression.

Errors inside a critical section. A throw between acquire and release leaks the lock. Wrap every critical section in try/finally so the release runs on the error path, and mirror the failure over postMessage so the main thread learns about it — shared memory carries state, but it carries no stack traces.

acquire(header, Slot.Lock);
try {
  mutateSharedRegion(payload);
} finally {
  release(header, Slot.Lock);   // runs even if mutateSharedRegion throws
}
Six shared-memory failures, the symptom each produces and the guard that prevents it SharedArrayBuffer is not defined means the constructor is missing in page and worker; assert crossOriginIsolated at startup. DataCloneError on postMessage throws synchronously on the sender; pass the buffer as data, never transferred. A TypeError from Atomics.wait means main-thread code hit a worker-only path; branch on context and use Atomics.waitAsync. A RangeError building a view means the offset is not a multiple of the element size; let one layout module own every offset. A lost wakeup leaves a worker sleeping on a condition already past; park on the value that means keep waiting. A lock leaked by a throw stalls every other agent permanently; release in finally and time out every wait. Six shared-memory failures, their symptom and their guard Failure What you actually see The guard that prevents it SharedArrayBuffer is not defined the constructor is missing in page and worker assert crossOriginIsolated at startup DataCloneError on postMessage throws synchronously on the sender pass the buffer as data, never transferred TypeError from Atomics.wait main-thread code hit a worker-only path branch on context, use Atomics.waitAsync RangeError building a view offset is not a multiple of the element size one layout module owns every offset Lost wakeup a worker sleeps on a condition already past park on the value that means keep waiting Lock leaked by a throw every other agent stalls, permanently release in finally, time out every wait Shared memory carries state but no stack traces — every failure still has to be mirrored over postMessage.
Four of the six are silent: no exception, no console output, nothing in the network panel. They are found by asserting invariants at startup and by timing out every wait, not by reading a stack trace.

Browser Compatibility

Feature Chrome Firefox Safari Edge
SharedArrayBuffer (cross-origin isolated) 92+ 79+ 15.2+ 92+
crossOriginIsolated property 87+ 72+ 15.2+ 87+
Atomics core (load/store/add/sub/and/or/xor/exchange/compareExchange) 68+ 78+ 15.2+ 79+
Atomics.wait (worker agents only) 68+ 78+ 15.2+ 79+
Atomics.notify 68+ 78+ 15.2+ 79+
Atomics.waitAsync 87+ 106+ 16.4+ 87+
Growable SharedArrayBuffer (maxByteLength / grow) 111+ 128+ 16.4+ 111+
WebAssembly threads (shared memory) 74+ 79+ 16.4+ 79+

Safari sets the floor at 15.2 (December 2021) for any shared-memory design, and at 16.4 if you depend on Atomics.waitAsync or growable buffers. Older Safari exposed the Atomics namespace without a usable SharedArrayBuffer, so detect the constructor rather than the namespace. Because the entire feature is gated on headers you control, the practical compatibility strategy is a runtime split: build the fast path on shared memory behind a crossOriginIsolated check, and keep a transfer-based path — the same algorithm, coarser batches — for contexts where isolation is unavailable. Any application embedding third-party iframes or ad tags that cannot supply Cross-Origin-Resource-Policy headers will need that fallback permanently.

Frequently Asked Questions

Why is SharedArrayBuffer undefined in my worker even though I created one?
Your document is not cross-origin isolated. Serve Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp on the document response, and make sure every cross-origin subresource it pulls in is CORP- or CORS-eligible — a single non-compliant image or script keeps crossOriginIsolated at false and the constructor stays undefined. The worker inherits isolation from its owner document, so there is nothing to fix inside the worker itself. Debugging SharedArrayBuffer Cross-Origin Errors walks the diagnosis.
Can I call Atomics.wait on the main thread?
No. Atomics.wait blocks the calling agent, and blocking the main thread would freeze rendering, input and timers, so the spec forbids it — the call throws a TypeError. Use Atomics.waitAsync on the main thread: it returns { async, value } where value is either a settled string or a Promise resolving to "ok" or "timed-out", so the event loop keeps turning while you wait.
What is the difference between Atomics.store and a plain array assignment?
A plain typed-array write (view[i] = x) is an unordered access: the JIT may hoist, sink, coalesce or eliminate it, and other agents have no guarantee of ever observing it. Atomics.store is a sequentially-consistent write that joins the single global order of atomic operations every agent agrees on, and it acts as a fence — plain writes issued before it are visible to any agent that observes the store through Atomics.load.
When should I choose SharedArrayBuffer over transferable ArrayBuffers?
Choose shared memory when two or more agents need concurrent access to the same bytes, or when coordination has to happen faster than a postMessage hop (0.1–0.5 ms) allows — ring buffers, shared counters, audio and simulation loops. If the data has exactly one owner at a time and moves in one direction, Transferable Objects & Zero-Copy gives you the same zero-copy benefit with none of the ordering hazards and no isolation headers.

See also