SharedArrayBuffer and Atomics
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.
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.
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);
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.
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 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');
}
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 do | What 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 |
- 1SharedArrayBuffer provides true shared memory between threads — no copying, but requires Atomics for safe access
- 2Atomics.compareExchange is the foundation of all lock-free data structures
- 3Atomics.wait blocks a worker thread until notified — use Atomics.waitAsync on the main thread
- 4Cross-origin isolation (COOP + COEP headers) is mandatory due to Spectre mitigations
- 5Use SharedArrayBuffer for high-frequency data sharing (game engines, audio, WASM threading) — use postMessage for everything else