Skip to content

SharedArrayBuffer and Atomics

advanced16 min read

Beyond Message Passing

In the previous topic, we learned that postMessage copies data between threads. That works great for most cases. But what if you're building a game engine that needs 60fps physics updates shared between a simulation worker and a render worker? Copying megabytes of position data 60 times per second is a non-starter.

SharedArrayBuffer gives you what no other browser API does: true shared memory. Two threads reading and writing the exact same bytes. No copies. No serialization. Just raw, fast, dangerous shared state.

And that's exactly why it came with Atomics — a set of low-level synchronization primitives to prevent your threads from stepping on each other.

Mental Model

Think of SharedArrayBuffer as a shared whiteboard between offices (threads). Everyone can read and write to the same board simultaneously. Without rules, two people writing at the same spot creates gibberish. Atomics are like a ticket system — they ensure operations happen one at a time at each spot, and let threads wait for each other's updates.

SharedArrayBuffer Basics

const sab = new SharedArrayBuffer(1024);

const view = new Int32Array(sab);

view[0] = 42;

worker.postMessage({ buffer: sab });

Notice: we pass sab via postMessage without the transfer list. Unlike ArrayBuffer transfer, the SharedArrayBuffer is shared, not moved. Both threads now reference the same memory.

Inside the worker:

self.onmessage = (event) => {
  const view = new Int32Array(event.data.buffer);

  console.log(view[0]); // 42 — same memory!
  view[0] = 99;         // main thread sees this change too
};

Both threads see the same bytes. Changes are visible (eventually) to all threads.

Quiz
What happens when you pass a SharedArrayBuffer via postMessage?

The Problem: Data Races

Shared memory without synchronization is a recipe for bugs:

// Thread A                    // Thread B
view[0] = 10;                  view[0] = 20;
const x = view[0];             const y = view[0];
// x could be 10 or 20         // y could be 10 or 20

Worse, non-atomic operations on values larger than a single byte can produce torn reads — you read half the old value and half the new value. A 64-bit write might be two 32-bit writes under the hood, and another thread could read between them.

This is not theoretical. It happens in production. It's the same class of bugs that plague C++ multithreaded programs.

Atomics: Safe Shared Memory

The Atomics object provides operations that are guaranteed to be indivisible (atomic). No thread can see a half-completed atomic operation.

Core Operations

const sab = new SharedArrayBuffer(16);
const view = new Int32Array(sab);

Atomics.store(view, 0, 42);

const value = Atomics.load(view, 0); // 42

const old = Atomics.exchange(view, 0, 99); // returns 42, sets to 99

Atomics.add(view, 0, 10);    // atomically adds 10
Atomics.sub(view, 0, 5);     // atomically subtracts 5
Atomics.and(view, 0, 0xFF);  // atomic bitwise AND
Atomics.or(view, 0, 0x0F);   // atomic bitwise OR

compareExchange: The Foundation of Lock-Free Code

compareExchange is the most powerful atomic. It says: "If the value at this index is what I expect, replace it with a new value. Otherwise, do nothing."

const expected = 42;
const replacement = 100;

const actual = Atomics.compareExchange(view, 0, expected, replacement);

if (actual === expected) {
  // success — value was 42, now it's 100
} else {
  // another thread changed it first — actual holds the current value
}

This is the building block for lock-free data structures. Every mutex, semaphore, and concurrent queue can be built from compareExchange.

Implementing a Simple Spinlock

const UNLOCKED = 0;
const LOCKED = 1;

function lock(view, index) {
  while (Atomics.compareExchange(view, index, UNLOCKED, LOCKED) !== UNLOCKED) {
    // spin — another thread holds the lock
  }
}

function unlock(view, index) {
  Atomics.store(view, index, UNLOCKED);
}

lock(view, 0);
// critical section — only one thread at a time
view[1] = computeResult();
unlock(view, 0);
Common Trap

Spinlocks burn CPU while waiting. They are only appropriate when the critical section is tiny (a few operations) and contention is low. For anything longer, use Atomics.wait and Atomics.notify — they block the thread without burning CPU.

Quiz
What does Atomics.compareExchange(view, 0, 42, 100) do?

wait and notify: Thread Coordination

Atomics.wait puts a thread to sleep until another thread wakes it with Atomics.notify. Unlike spinlocks, waiting threads consume zero CPU.

// Worker thread — waits for data
const result = Atomics.wait(view, 0, 0);
// Blocks until view[0] is no longer 0
// result: 'ok' | 'not-equal' | 'timed-out'
// Main thread — signals data is ready
Atomics.store(view, 0, 1);
Atomics.notify(view, 0, 1); // wake 1 waiting thread
Atomics.wait cannot run on the main thread

Atomics.wait blocks the calling thread. Blocking the main thread would freeze the entire page — no rendering, no input, no nothing. Browsers throw a TypeError if you call Atomics.wait on the main thread. Use Atomics.waitAsync instead, which returns a Promise that resolves when notified.

waitAsync: Non-blocking Wait for the Main Thread

const { async: isAsync, value } = Atomics.waitAsync(view, 0, 0);

if (isAsync) {
  value.then((result) => {
    console.log('Worker signaled!', result);
  });
}

Atomics.waitAsync integrates with the event loop — it returns a Promise that resolves when the condition is met, without blocking the main thread.

Why Cross-Origin Isolation? The Spectre Story

You might wonder why SharedArrayBuffer was disabled in every browser from January 2018 to mid-2020. The answer is a CPU vulnerability called Spectre.

Spectre exploits speculative execution in CPUs to read memory that should be off-limits. To measure which memory was accessed speculatively, attackers need a high-resolution timer. SharedArrayBuffer with a worker running a tight loop creates a timer with nanosecond precision — far more precise than performance.now().

The fix: browsers require cross-origin isolation before enabling SharedArrayBuffer. This means your page must prove it is not embedding untrusted cross-origin resources that an attacker could read via Spectre.

Required Headers

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

COOP: same-origin isolates your page's browsing context group — no other origin can get a reference to your window. COEP: require-corp ensures every resource your page loads either comes from the same origin or explicitly opts into being loaded cross-origin (via CORS or Cross-Origin-Resource-Policy).

Together, these headers tell the browser: "This page is fully isolated. It's safe to enable shared memory."

// Check if cross-origin isolated
if (crossOriginIsolated) {
  const sab = new SharedArrayBuffer(1024);
  // good to go
} else {
  console.warn('SharedArrayBuffer not available — missing COOP/COEP headers');
}
Quiz
Why does SharedArrayBuffer require cross-origin isolation headers (COOP/COEP)?

COEP credentialless Mode

require-corp is strict — every cross-origin resource needs explicit headers. For pages that embed ads, analytics, or third-party images, this is painful. The credentialless mode offers a middle ground:

Cross-Origin-Embedder-Policy: credentialless

Resources are loaded without credentials (cookies, client certs). Since the response is not personalized, Spectre leaking it is less dangerous. This mode is supported in Chrome 96+ and has been gaining broader support.

Real-World Use Cases

High-Performance Computing

Game engines and physics simulations share state between a simulation thread and a render thread:

const positions = new Float32Array(sab, 0, entityCount * 3);
const velocities = new Float32Array(sab, entityCount * 12, entityCount * 3);

// Physics worker updates positions
for (let i = 0; i < entityCount; i++) {
  Atomics.store(posView, i * 3, newX);
  Atomics.store(posView, i * 3 + 1, newY);
  Atomics.store(posView, i * 3 + 2, newZ);
}
Atomics.notify(flagView, 0);

WASM Threading

WebAssembly uses SharedArrayBuffer as its shared linear memory when running multi-threaded WASM modules. Compiling C++ with -pthread flag generates code that relies on SharedArrayBuffer and Atomics under the hood.

Audio Processing

AudioWorkletProcessor can share an SharedArrayBuffer with the main thread for real-time audio parameter control without the jitter of postMessage.

Lock-Free Ring Buffer

A practical pattern for producer-consumer scenarios:

const BUFFER_SIZE = 1024;
const sab = new SharedArrayBuffer(
  4 + 4 + BUFFER_SIZE * 4 // head + tail + data
);
const meta = new Int32Array(sab, 0, 2);
const data = new Int32Array(sab, 8, BUFFER_SIZE);

function produce(value) {
  const head = Atomics.load(meta, 0);
  const tail = Atomics.load(meta, 1);
  const next = (head + 1) % BUFFER_SIZE;

  if (next === tail) return false; // buffer full

  data[head] = value;
  Atomics.store(meta, 0, next);
  Atomics.notify(meta, 1, 1);
  return true;
}

function consume() {
  const tail = Atomics.load(meta, 1);
  const head = Atomics.load(meta, 0);

  if (tail === head) {
    Atomics.wait(meta, 1, tail); // wait for data
    return consume();
  }

  const value = data[tail];
  Atomics.store(meta, 1, (tail + 1) % BUFFER_SIZE);
  return value;
}
What developers doWhat they should do
Using regular reads/writes on SharedArrayBuffer without Atomics
Without Atomics, the compiler and CPU can reorder operations, and you can get torn reads on multi-byte values. Atomics enforce ordering and atomicity.
Always use Atomics.load/store for values shared between threads, and Atomics.compareExchange for read-modify-write patterns
Calling Atomics.wait on the main thread
Atomics.wait blocks the calling thread. Blocking the main thread freezes the entire page. Browsers throw TypeError if you try.
Use Atomics.waitAsync on the main thread, which returns a Promise instead of blocking
Deploying SharedArrayBuffer without COOP/COEP headers
Browsers disable SharedArrayBuffer without cross-origin isolation to mitigate Spectre timing attacks. Check crossOriginIsolated === true before using it.
Set Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp (or credentialless) on your server
Key Rules
  1. 1SharedArrayBuffer provides true shared memory between threads — no copying, but requires Atomics for safe access
  2. 2Atomics.compareExchange is the foundation of all lock-free data structures
  3. 3Atomics.wait blocks a worker thread until notified — use Atomics.waitAsync on the main thread
  4. 4Cross-origin isolation (COOP + COEP headers) is mandatory due to Spectre mitigations
  5. 5Use SharedArrayBuffer for high-frequency data sharing (game engines, audio, WASM threading) — use postMessage for everything else