Shared Memory & Atomics
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.
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:
| Mechanism | Data sharing | Copy cost | Thread safety |
|---|---|---|---|
| Structured clone | Independent copies | O(n) | Safe (no shared state) |
| Transfer | Exclusive ownership moves | O(1) | Safe (only one owner) |
| SharedArrayBuffer | True shared memory | None | Unsafe (data races possible) |
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);
}
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.
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.
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'
}
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;
}
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.
| What developers do | What 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
Try to solve it before peeking at the answer.
// 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 loopKey Rules
- 1SharedArrayBuffer provides true shared memory — both threads access the same bytes. No copy, no transfer of ownership.
- 2Non-atomic operations on shared memory cause data races. Always use Atomics methods for any location accessed by multiple threads.
- 3Cross-origin isolation (COOP/COEP headers) is required for SharedArrayBuffer. Without it, the constructor is not available.
- 4Atomics.wait blocks the calling thread — only use it in workers. Use Atomics.waitAsync for non-blocking waits on the main thread.
- 5SharedArrayBuffer only stores fixed-size numeric types. For complex data, use it for coordination (flags, indices) and postMessage for payloads.