Skip to content

Shared Memory & Atomics

advanced22 min read

The Data Race You Can't See

What does this print?

// main.js
const sab = new SharedArrayBuffer(4);
const view = new Int32Array(sab);
view[0] = 0;

const worker = new Worker('/workers/counter.js', { type: 'module' });
worker.postMessage(sab);

for (let i = 0; i < 1_000_000; i++) {
  view[0]++;
}

setTimeout(() => console.log(view[0]), 1000);
// counter.js
self.onmessage = (event) => {
  const view = new Int32Array(event.data);
  for (let i = 0; i < 1_000_000; i++) {
    view[0]++;
  }
};

You might expect 2,000,000. But the actual result is somewhere between 1,000,000 and 2,000,000 — different every run. This is a classic data race: both threads read the same value, increment it, and write back, overwriting each other's work. The ++ operator is NOT atomic — it's a read, an add, and a write. Between the read and the write, the other thread can modify the same memory location.

This is the fundamental challenge of shared memory: zero-copy access to the same data comes at the cost of correctness guarantees.

Mental Model

Imagine two people editing the same Google Doc without real-time sync. Person A reads "count: 5", adds 1, and types "count: 6". But Person B also read "count: 5", added 1, and typed "count: 6". Two increments happened, but the count only went up by 1. This is a lost update — the exact bug in our code. Atomics is like adding real-time sync: one person edits at a time, and everyone sees the latest value before making changes.

SharedArrayBuffer: Same Memory, Multiple Threads

Unlike regular ArrayBuffer (which is transferred or cloned), SharedArrayBuffer is genuinely shared. When you send it via postMessage, both threads get views into the same underlying memory:

const sab = new SharedArrayBuffer(1024);
const mainView = new Int32Array(sab);
mainView[0] = 42;

worker.postMessage(sab);
// The worker receives the SAME memory, not a copy
// No transfer, no clone — both threads see the same bytes
// worker.js
self.onmessage = (event) => {
  const workerView = new Int32Array(event.data);
  console.log(workerView[0]); // 42 — reading main thread's write
  workerView[0] = 100;
  // Main thread's mainView[0] is now 100 too
};

This is fundamentally different from postMessage with clone or transfer:

MechanismData sharingCopy costThread safety
Structured cloneIndependent copiesO(n)Safe (no shared state)
TransferExclusive ownership movesO(1)Safe (only one owner)
SharedArrayBufferTrue shared memoryNoneUnsafe (data races possible)
Quiz
What makes SharedArrayBuffer fundamentally different from transferring an ArrayBuffer?

The COOP/COEP Requirement

After the Spectre CPU vulnerability was disclosed in 2018, browsers restricted SharedArrayBuffer because shared memory could be used to build high-resolution timers for side-channel attacks. To re-enable it, your page must be cross-origin isolated:

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

These headers tell the browser: "this page doesn't load cross-origin resources that haven't opted in, and it doesn't share a browsing context group with cross-origin pages." This isolation makes Spectre-style attacks impractical.

// Check if SharedArrayBuffer is available
if (typeof SharedArrayBuffer !== 'undefined') {
  console.log('SharedArrayBuffer available');
} else {
  console.log('Page is not cross-origin isolated');
  console.log('crossOriginIsolated:', self.crossOriginIsolated);
}
COEP Breaks Third-Party Resources

Setting Cross-Origin-Embedder-Policy: require-corp means every resource loaded by your page must either be same-origin or include a Cross-Origin-Resource-Policy: cross-origin header. This breaks many third-party scripts, images, and iframes that do not set this header. The credentialless COEP mode relaxes this for no-credentials requests but is not supported in Safari as of 2025.

Quiz
Why do browsers require cross-origin isolation (COOP/COEP headers) to enable SharedArrayBuffer?

Atomics: Making Shared Memory Safe

The Atomics object provides atomic operations on SharedArrayBuffer — operations that are guaranteed to complete without interruption from other threads:

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

// Atomic increment — no data race
Atomics.add(view, 0, 1);  // atomically: view[0] += 1

// Atomic read — guaranteed to see the latest write
const value = Atomics.load(view, 0);

// Atomic write — guaranteed to be visible to other threads
Atomics.store(view, 0, 42);

// Compare-and-swap — the foundation of lock-free algorithms
const old = Atomics.compareExchange(view, 0, 42, 100);
// If view[0] was 42, it's now 100, and old === 42
// If view[0] was NOT 42, nothing changed, and old === current value

Let's fix the counter example from the opening:

// main.js — fixed with Atomics
const sab = new SharedArrayBuffer(4);
const view = new Int32Array(sab);
Atomics.store(view, 0, 0);

const worker = new Worker('/workers/counter.js', { type: 'module' });
worker.postMessage(sab);

for (let i = 0; i < 1_000_000; i++) {
  Atomics.add(view, 0, 1); // Atomic increment
}

setTimeout(() => {
  console.log(Atomics.load(view, 0)); // Exactly 2,000,000
}, 2000);
// counter.js — fixed
self.onmessage = (event) => {
  const view = new Int32Array(event.data);
  for (let i = 0; i < 1_000_000; i++) {
    Atomics.add(view, 0, 1); // Atomic increment
  }
};

Now the result is always exactly 2,000,000.

Quiz
Why does using Atomics.add instead of the ++ operator fix the data race?

Complete Atomics API

// Arithmetic
Atomics.add(view, index, value)        // view[index] += value, returns old
Atomics.sub(view, index, value)        // view[index] -= value, returns old

// Bitwise
Atomics.and(view, index, value)        // view[index] &= value, returns old
Atomics.or(view, index, value)         // view[index] |= value, returns old
Atomics.xor(view, index, value)        // view[index] ^= value, returns old

// Exchange
Atomics.exchange(view, index, value)   // view[index] = value, returns old
Atomics.compareExchange(view, index, expected, replacement)

// Load/Store
Atomics.load(view, index)              // read with acquire semantics
Atomics.store(view, index, value)      // write with release semantics

// Synchronization
Atomics.wait(view, index, value, timeout)    // block until notified (workers only)
Atomics.waitAsync(view, index, value, timeout) // non-blocking wait (returns Promise)
Atomics.notify(view, index, count)     // wake waiting threads

Wait and Notify: Thread Synchronization

Atomics.wait blocks a worker thread until another thread calls Atomics.notify. This is how you implement mutexes, barriers, and condition variables in JavaScript:

// worker.js — wait for the main thread to signal "data is ready"
self.onmessage = (event) => {
  const control = new Int32Array(event.data);

  // Block until control[0] changes from 0
  const result = Atomics.wait(control, 0, 0);
  // result is 'ok' (notified), 'not-equal' (already changed), or 'timed-out'

  if (result === 'ok' || result === 'not-equal') {
    processData(control);
  }
};
// main.js — signal the worker
const sab = new SharedArrayBuffer(1024);
const control = new Int32Array(sab);
worker.postMessage(sab);

// Prepare data...
fillDataInBuffer(control);

// Signal the worker: "data is ready"
Atomics.store(control, 0, 1);     // Write the signal
Atomics.notify(control, 0, 1);    // Wake one waiting thread

Atomics.wait is blocking — it freezes the calling thread. That's why it's only allowed in workers, never on the main thread (calling it on the main thread throws). For the main thread, use Atomics.waitAsync, which returns a Promise:

// Main thread: non-blocking wait
const result = Atomics.waitAsync(control, 0, 0);
if (result.async) {
  result.value.then((status) => {
    console.log('Worker signaled:', status); // 'ok' or 'timed-out'
  });
} else {
  console.log('Already changed:', result.value); // 'not-equal'
}
Quiz
Why is Atomics.wait not allowed on the main thread?

Building a Spinlock with Atomics

A spinlock is the simplest mutex: a thread repeatedly checks a flag until it's free, then claims it. Not ideal for general use (busy-waiting wastes CPU), but useful for very short critical sections:

const UNLOCKED = 0;
const LOCKED = 1;

function lock(view, index) {
  while (Atomics.compareExchange(view, index, UNLOCKED, LOCKED) !== UNLOCKED) {
    // Spin — the lock is held by another thread
    // In a real implementation, you'd yield or use Atomics.wait
  }
}

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

A better approach using Atomics.wait to avoid busy-waiting:

function lock(view, index) {
  while (true) {
    const prev = Atomics.compareExchange(view, index, UNLOCKED, LOCKED);
    if (prev === UNLOCKED) return; // We got the lock
    Atomics.wait(view, index, LOCKED); // Sleep until notified
  }
}

function unlock(view, index) {
  Atomics.store(view, index, UNLOCKED);
  Atomics.notify(view, index, 1); // Wake one waiting thread
}

Shared Memory Layout Design

For real applications, you need a structured layout for the shared buffer. Here's a pattern for a shared work queue:

// Memory layout:
// [0]    lock (Int32)
// [1]    head index (Int32)
// [2]    tail index (Int32)
// [3]    item count (Int32)
// [4..N] data slots (Int32 each)

const LOCK = 0;
const HEAD = 1;
const TAIL = 2;
const COUNT = 3;
const DATA_START = 4;
const QUEUE_SIZE = 1024;

const sab = new SharedArrayBuffer((DATA_START + QUEUE_SIZE) * 4);
const view = new Int32Array(sab);

function enqueue(view, value) {
  lock(view, LOCK);
  const tail = Atomics.load(view, TAIL);
  const count = Atomics.load(view, COUNT);
  if (count >= QUEUE_SIZE) {
    unlock(view, LOCK);
    return false;
  }
  Atomics.store(view, DATA_START + tail, value);
  Atomics.store(view, TAIL, (tail + 1) % QUEUE_SIZE);
  Atomics.add(view, COUNT, 1);
  unlock(view, LOCK);
  return true;
}

function dequeue(view) {
  lock(view, LOCK);
  const count = Atomics.load(view, COUNT);
  if (count === 0) {
    unlock(view, LOCK);
    return undefined;
  }
  const head = Atomics.load(view, HEAD);
  const value = Atomics.load(view, DATA_START + head);
  Atomics.store(view, HEAD, (head + 1) % QUEUE_SIZE);
  Atomics.sub(view, COUNT, 1);
  unlock(view, LOCK);
  return value;
}
Common Trap

SharedArrayBuffer only stores fixed-size numeric types via TypedArray views. You cannot store strings, objects, or variable-length data directly. For complex data, you need to design your own binary encoding (length-prefixed strings, fixed-width struct fields) or use SharedArrayBuffer only for coordination (flags, counters, indices) while sending the actual data via postMessage.

Quiz
You need to share a queue of string messages between workers using SharedArrayBuffer. What is the most practical approach?
What developers doWhat they should do
Using non-atomic operations (view[0]++) on SharedArrayBuffer data accessed by multiple threads
Non-atomic operations are compiled into multiple CPU instructions (read, modify, write). Another thread can interleave between these steps, causing data races. Atomics uses hardware-level atomic instructions that cannot be interrupted.
Always use Atomics methods (Atomics.add, Atomics.load, Atomics.store) for any shared memory location accessed by more than one thread
Using Atomics.wait on the main thread
Atomics.wait blocks the calling thread completely. On the main thread, this freezes the entire UI — no input, no rendering, no animations. Atomics.waitAsync returns a Promise, allowing the main thread event loop to continue.
Use Atomics.waitAsync on the main thread, Atomics.wait only in workers
Forgetting COOP/COEP headers and being confused why SharedArrayBuffer is undefined
After Spectre, browsers require cross-origin isolation for SharedArrayBuffer. Without the correct headers, SharedArrayBuffer is not available and typeof SharedArrayBuffer returns 'undefined'. Check self.crossOriginIsolated to verify.
Set Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp headers
Using SharedArrayBuffer for simple one-shot data transfer between main thread and worker
SharedArrayBuffer adds complexity (atomic operations, data race risks, COOP/COEP headers) that is unnecessary for simple data transfer. Transferable ArrayBuffer achieves zero-copy transfer without any of these concerns. Use SharedArrayBuffer only when multiple threads need ongoing access to the same data.
Use transferable ArrayBuffer for one-shot transfers. SharedArrayBuffer is for continuous shared state

Challenge: Lock-Free Cancellation Flag

Challenge: Atomic Cancellation

Try to solve it before peeking at the answer.

javascript
// Build a cancellation system using SharedArrayBuffer where:
// 1. Main thread can cancel a worker's long-running operation instantly
// 2. Worker checks the cancellation flag without any postMessage overhead
// 3. Multiple workers share the same cancellation buffer
// 4. Each worker has its own cancel slot (workers don't interfere)
//
// This is faster than the postMessage-based cancellation from the
// previous topic because there's no message queue delay.
//
// Layout: Int32Array where index N is the cancel flag for worker N
// 0 = running, 1 = cancelled

// main.js
function createCancellablePool(workerUrl, size) {
// Your code
}

// worker.js
// Your code: check cancellation flag in tight loop

Key Rules

Key Rules
  1. 1SharedArrayBuffer provides true shared memory — both threads access the same bytes. No copy, no transfer of ownership.
  2. 2Non-atomic operations on shared memory cause data races. Always use Atomics methods for any location accessed by multiple threads.
  3. 3Cross-origin isolation (COOP/COEP headers) is required for SharedArrayBuffer. Without it, the constructor is not available.
  4. 4Atomics.wait blocks the calling thread — only use it in workers. Use Atomics.waitAsync for non-blocking waits on the main thread.
  5. 5SharedArrayBuffer only stores fixed-size numeric types. For complex data, use it for coordination (flags, indices) and postMessage for payloads.