SharedArrayBuffer and Atomics

JavaScript · Expert · 20 min

True multi-threaded algorithms in JavaScript (WebAssembly threads, parallel compute) depend on shared memory.

Workers normally communicate with postMessage, which copies data via structured clone (or transfers ownership of an ArrayBuffer). A SharedArrayBuffer is different: sending it to a worker shares the same memory, so several agents read and write the same bytes concurrently. You access it through typed arrays such as Int32Array.

Because of Spectre-style timing attacks, browsers only enable SharedArrayBuffer on cross-origin isolated pages: serve the document with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp (or credentialless), and check crossOriginIsolated. Node.js has no such restriction.

Ordinary reads and writes to shared memory are data races waiting to happen. counter[0]++ is a read, an add and a write; two threads interleaving those steps lose updates. The Atomics object provides indivisible operations:

  • Atomics.load / Atomics.store — reads and writes with ordering guarantees.
  • Atomics.add, sub, and, or, xor, exchange — read-modify-write, returning the old value.
  • Atomics.compareExchange — the building block for locks and lock-free structures.

For coordination, Atomics.wait(int32, index, expected) blocks the thread until another thread calls Atomics.notify(int32, index, count). Blocking is not allowed on the browser's main thread (it would freeze the page); there use Atomics.waitAsync (ES2024), which returns a promise.

Atomic operations are sequentially consistent: all agents observe them in a single total order, and they order the plain accesses around them. Racy non-atomic accesses are still well-defined enough not to crash, but their results can be stale or surprising.

Shared memory is low-level: you're manually managing layout in a byte array. Prefer message passing unless profiling proves you need it.

Example

// counter.mjs — run with: node counter.mjs
import { Worker, isMainThread, workerData } from "node:worker_threads";

if (isMainThread) {
  const shared = new SharedArrayBuffer(8);       // two Int32 slots
  const view = new Int32Array(shared);
  const workers = Array.from({ length: 4 }, () =>
    new Worker(new URL(import.meta.url), { workerData: shared }));
  await Promise.all(workers.map(w => new Promise(r => w.on("exit", r))));

  console.log("atomic:", Atomics.load(view, 0)); // atomic: 400000 (always)
  console.log("racy:  ", view[1]);               // racy: often less than 400000
} else {
  const view = new Int32Array(workerData);       // same memory, not a copy
  for (let i = 0; i < 100_000; i++) {
    Atomics.add(view, 0, 1);                     // indivisible read-modify-write
    view[1]++;                                   // data race: updates can be lost
  }
}

Remember

  • SharedArrayBuffer shares memory between agents instead of copying it
  • Browsers require cross-origin isolation (COOP + COEP) to enable it
  • Use Atomics for read-modify-write and wait/notify; never block the main thread

Watch out for

  • Incrementing a shared counter with view[i]++ from several workers.

Check yourself

  1. Four workers each run view[0]++ 100,000 times on a shared Int32Array. Why can the final value be less than 400,000?
  2. Why can't the browser main thread call Atomics.wait?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer

Builds on

Unlocks

Nothing builds on this yet.