Shared Memory, Atomics, and the ECMAScript Memory Model
JavaScript is single-threaded per agent. Agents in a cluster are not, and the specification has a formal memory model to prove it.
SharedArrayBuffer gives multiple agents a view onto the same bytes. To say what a program that races on those bytes may observe, ECMA-262 §29 defines a memory model in the style of C++11: events, happens-before, synchronizes-with, sequentially consistent atomics, and a precise definition of data races. This chapter states the model, shows what it permits, and covers the host-level machinery (agent clusters, cross-origin isolation, structured clone and transfer) that surrounds it.
In this chapter
Agent clusters and what can be shared
An agent cluster is the maximal set of agents that can share memory. In a browser, a window and its dedicated workers (and same-origin frames in the same browsing context group) form one cluster; a service worker is in its own. In Node, the main thread and its worker_threads form one cluster. Only Shared Data Blocks, the backing stores of SharedArrayBuffers and of WebAssembly.Memory created with shared: true, are shared. Everything else that crosses postMessage is structured-cloned (deep copied by the HTML structured serialisation algorithm, which understands Map, Set, Date, RegExp, ArrayBuffer, typed arrays, Error, and platform objects, but not functions, symbols, or prototype chains) or transferred (the ArrayBuffer's backing store is moved and the sender's buffer becomes detached, byteLength 0).
The memory model: events, orders, and races
§29 models an execution as a set of events: ReadSharedMemory, WriteSharedMemory, and ReadModifyWriteSharedMemory, each with a byte range and an order attribute, SeqCst, Unordered, or Init. Ordinary typed-array accesses to shared memory produce Unordered events; the Atomics functions produce SeqCst events (the specification currently defines no acquire/release or relaxed atomics, although it leaves room). A candidate execution assigns to each read the write(s) it reads from and includes several relations over events:
- agent-order: the per-agent program order of events.
- reads-bytes-from: which write each byte of a read takes its value from.
- synchronizes-with: a SeqCst read that reads from a SeqCst write of the same range is synchronised with it; also
Atomics.wait/notifypairs and the Init event of a shared block with every read of it. - happens-before: the transitive closure of agent-order ∪ synchronizes-with ∪ (host-provided edges such as
postMessagesend/receive). This is the ordinary causality relation. - memory-order: a total order over all events consistent with happens-before and with the SeqCst constraints (every SeqCst event agrees on a single global order).
A candidate execution is valid if it satisfies coherence (a read cannot see a write that is happens-before an earlier-seen write), tear-freedom for aligned accesses, and sequential consistency for atomics. A program has a data race if two events on overlapping bytes, at least one a write, at least one not SeqCst (or not of the same range), are not ordered by happens-before. The critical design choice: data races do not make the program undefined. Unlike C++, a racy JavaScript program has well-defined, if non-deterministic, behaviour: a racy read returns some value, possibly a mix of bytes from different writes, and can never produce a value out of thin air, crash the engine, or violate memory safety. Type safety is preserved because shared memory only ever holds raw numbers.
Tearing
The model guarantees that an aligned, non-atomic access of a size the hardware supports (up to 8 bytes on every mainstream platform) is tear-free: a read observes a single write's bytes for the whole access. But two different-sized views onto the same bytes (Uint8Array and Uint32Array on one buffer) or unaligned DataView accesses can tear: a 32-bit read can see two bytes from one 8-bit write and two from another. Float64Array accesses on shared memory are tear-free only where the implementation can perform a 64-bit access atomically; the specification allows 64-bit non-atomic accesses to be treated as two 32-bit halves on 32-bit hardware (Atomics functions do not accept floating-point typed arrays at all for this reason).
Atomics: read-modify-write, and wait/notify as a futex
Atomics.load/store/add/sub/and/or/xor/exchange/compareExchange are the SeqCst primitives; each is a single ReadModifyWriteSharedMemory event (or a SeqCst read/write). Atomics.isLockFree(n) reports whether the hardware provides lock-free atomics for n-byte values (always true for 1, 2, 4; usually for 8). Atomics.wait(i32, index, expected, timeout) is a futex: it atomically compares i32[index] with expected and, if equal, blocks the calling agent (which must have [[CanBlock]] = true, so never the main thread) until another agent calls Atomics.notify(i32, index, count) or the timeout elapses, returning "ok", "not-equal", or "timed-out". The compare-then-sleep is atomic with respect to notify, which eliminates the lost-wakeup race that a naive spin-then-sleep loop has. Atomics.waitAsync (ES2024) returns a promise instead of blocking and is therefore usable on the main thread; it is the primitive that makes it possible to build mutexes and condition variables that the UI thread can participate in without busy-waiting.
if (typeof SharedArrayBuffer === "undefined") {
console.log("SharedArrayBuffer unavailable: this context is not cross-origin isolated (crossOriginIsolated =", globalThis.crossOriginIsolated, ").");
} else {
const WORKERS = 4, ITER = 200_000;
const sab = new SharedArrayBuffer(8);
const view = new Int32Array(sab); // [0] = racy counter, [1] = atomic counter
const workerSrc = `
onmessage = ({ data: { sab, iter } }) => {
const v = new Int32Array(sab);
for (let i = 0; i < iter; i++) { v[0] = v[0] + 1; } // Unordered read + Unordered write: a race
for (let i = 0; i < iter; i++) Atomics.add(v, 1, 1); // one SeqCst ReadModifyWrite event
postMessage("done");
};`;
const url = URL.createObjectURL(new Blob([workerSrc], { type: "text/javascript" }));
const t0 = performance.now();
await Promise.all(Array.from({ length: WORKERS }, () => new Promise((resolve) => {
const w = new Worker(url);
w.onmessage = () => { w.terminate(); resolve(); };
w.postMessage({ sab, iter: ITER });
})));
URL.revokeObjectURL(url);
console.log("expected:", WORKERS * ITER);
console.log("racy v[0] = v[0] + 1 :", view[0], view[0] === WORKERS * ITER ? "(no lost updates this time; the race is still a race)" : "(lost updates)");
console.log("Atomics.add :", Atomics.load(view, 1));
console.log((performance.now() - t0).toFixed(0), "ms");
}Whether the racy counter loses updates depends on scheduling; on a multi-core machine with four workers it almost always does. The point is that the result is some integer, never a crash and never a value that no write produced.
if (typeof SharedArrayBuffer === "undefined") {
console.log("SharedArrayBuffer unavailable in this context.");
} else {
// Classic futex-based lock: 0 = unlocked, 1 = locked, 2 = locked with waiters.
const lockSrc = `
function lock(i32, idx) {
let c = Atomics.compareExchange(i32, idx, 0, 1);
if (c !== 0) {
do {
if (c === 2 || Atomics.compareExchange(i32, idx, 1, 2) !== 0) Atomics.wait(i32, idx, 2);
} while ((c = Atomics.compareExchange(i32, idx, 0, 2)) !== 0);
}
}
function unlock(i32, idx) {
if (Atomics.sub(i32, idx, 1) !== 1) { Atomics.store(i32, idx, 0); Atomics.notify(i32, idx, 1); }
}
onmessage = ({ data: { sab, iter } }) => {
const i32 = new Int32Array(sab);
for (let k = 0; k < iter; k++) {
lock(i32, 0);
i32[1] = i32[1] + 1; // plain, non-atomic increment: safe because it is inside the critical section
unlock(i32, 0);
}
postMessage("done");
};`;
const sab = new SharedArrayBuffer(8);
const url = URL.createObjectURL(new Blob([lockSrc], { type: "text/javascript" }));
const WORKERS = 4, ITER = 50_000;
const t0 = performance.now();
await Promise.all(Array.from({ length: WORKERS }, () => new Promise((resolve) => {
const w = new Worker(url); w.onmessage = () => { w.terminate(); resolve(); }; w.postMessage({ sab, iter: ITER });
})));
URL.revokeObjectURL(url);
console.log("guarded counter:", new Int32Array(sab)[1], "expected:", WORKERS * ITER, "in", (performance.now() - t0).toFixed(0), "ms");
console.log("The unlock's Atomics.sub/store synchronise-with the next lock's compareExchange, so the plain writes happen-before the next reader's plain reads.");
}Where shared memory is actually used
- WebAssembly threads:
WebAssembly.Memory({ shared: true })is a SharedArrayBuffer; pthreads compiled with Emscripten map onto workers plusAtomics.wait/notify. This is the dominant real-world user of the memory model. - Audio and real-time pipelines:
AudioWorkletprocessors read from a lock-free ring buffer in a SharedArrayBuffer written by the main thread or a worker, becausepostMessageper audio quantum (128 frames, ~2.7 ms at 48 kHz) has unacceptable jitter. - Synchronous file systems and shims: WASI polyfills and
Atomics.wait-based synchronous XHR replacements block a worker until the main thread fills a shared buffer, making asynchronous host APIs appear synchronous to compiled code. - Zero-copy parallel data processing: parallel image filters, sorting, and numeric kernels partition a shared
Float32Arrayacross workers; the only coordination is a barrier built fromAtomics.addandAtomics.wait.
Where these ideas come from
The mechanisms in this chapter, traced to the paper, standard, or system that introduced them, and how each reached JavaScript.
| Mechanism | Origin | Path into JavaScript |
|---|---|---|
| Mutual exclusion and semaphores | Edsger W. Dijkstra, Cooperating Sequential Processes (1965) | The mutex built from compareExchange and wait/notify in this chapter is Dijkstra's P/V discipline over a futex. |
| happens-before and sequential consistency | Leslie Lamport, Time, Clocks, and the Ordering of Events in a Distributed System (1978); How to Make a Multiprocessor Computer That Correctly Executes Multiprocess Programs (1979) (1979) | ECMA-262 §29's happens-before relation and SeqCst atomics are Lamport's definitions. |
| Data-race semantics without undefined behaviour | Hans-J. Boehm & Sarita Adve; Lars T Hansen, Foundations of the C++ Concurrency Memory Model (2008); ECMAScript Shared Memory and Atomics (2017) (2017) | The JavaScript model is a C++11-style model with the crucial change that racy programs stay well-defined, because the language must remain memory-safe. |
| Futexes | Hubertus Franke, Rusty Russell & Matthew Kirkwood, Fuss, Futexes and Furwocks: Fast Userlevel Locking in Linux (2002) | Atomics.wait and Atomics.notify are futex_wait and futex_wake; the atomic compare-then-sleep is the futex's defining property. |
| Speculative side channels forcing isolation | Paul Kocher, Jann Horn, et al., Spectre Attacks: Exploiting Speculative Execution (2018) | Why SharedArrayBuffer was withdrawn in 2018 and returned only under cross-origin isolation. |
Primary sources
- ECMA-262, §29 Memory Model
- ECMA-262, §25.4 The Atomics Object
- ECMA-262, §25.2 SharedArrayBuffer Objects
- HTML Living Standard: agent clusters and cross-origin isolation
- web.dev: Making your website cross-origin isolated using COOP and COEP
- Lars T Hansen: A memory model for ECMAScript shared memory (design rationale)