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.
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 === truein 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
Atomicsobject years before it shipped a usableSharedArrayBuffer, 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
postMessageprotocol 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.
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.
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.
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.
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.
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.
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.
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.
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”.
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.
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
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.
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.
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.
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”.
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.
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
}
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.