SharedArrayBuffer and Atomics
JavaScript runs on a single thread — that is, it did for a long time. The event loop handles async callbacks, but you only ever had one thread of execution. Then Web Workers arrived, giving you actual separate threads with separate JavaScript contexts. The problem: how do those threads share data efficiently?
SharedArrayBuffer and the Atomics namespace answer that. Together they give you shared memory between threads, with atomic operations that prevent torn reads and writes. The catch: you need cross-origin isolation, and the APIs are low-level. This guide covers what these primitives actually do and the patterns you’ll use in practice.
What SharedArrayBuffer Does Differently
A regular ArrayBuffer is a raw byte buffer, but it’s tied to one context. When you transfer an ArrayBuffer via postMessage, ownership moves — the sender loses access. You end up with two independent buffers containing the same data.
SharedArrayBuffer changes this. When you pass one to a worker via postMessage, both contexts keep a reference to the same underlying memory block. There is only one set of bytes. Writes from one thread are immediately visible to the other.
// Main thread
const sab = new SharedArrayBuffer(1024);
worker.postMessage(sab);
// Worker
self.onmessage = (e) => {
const sab = e.data; // Same memory as the main thread's sab
const bytes = new Uint8Array(sab);
bytes[0] = 42; // Visible immediately to main thread
};
Construct it like a regular ArrayBuffer:
const sab = new SharedArrayBuffer(256);
console.log(sab.byteLength); // 256
// Growable variant (ES2021+)
const growable = new SharedArrayBuffer(256, { maxByteLength: 4096 });
console.log(growable.growable); // true
console.log(growable.maxByteLength); // 4096
The memory starts zeroed. You access it through typed arrays, just like a regular ArrayBuffer:
const sab = new SharedArrayBuffer(64);
const int32 = new Int32Array(sab); // 64 bytes / 4 = 16 Int32 slots
const uint8 = new Uint8Array(sab); // 64 bytes of Uint8 slots
The Cross-Origin Isolation Requirement
This is the part that trips people up. SharedArrayBuffer was disabled across browsers after the Spectre and Meltdown CPU vulnerabilities became public in early 2018. The attack relied on measuring memory access timing — and shared memory made that easier. Browsers pulled the feature.
It came back once the web standards community figured out a mitigation: cross-origin isolation. For SharedArrayBuffer to work, your page needs to send two HTTP headers:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
COOP (same-origin) severs the communication channel that Spectre exploits — cross-origin pages that open your page end up in a separate browsing context group. COEP (require-corp) blocks cross-origin resources from loading unless they explicitly opt in via the Cross-Origin-Resource-Policy header. Together they create a “cross-origin isolated” environment where shared memory can’t be exploited via timing side channels.
You can check whether your page is isolated:
if (self.crossOriginIsolated) {
// SharedArrayBuffer fully available
const sab = new SharedArrayBuffer(16);
} else {
// Must fall back to regular ArrayBuffer — no true sharing
const ab = new ArrayBuffer(16);
}
COEP is the painful one in practice. require-corp blocks cross-origin images, scripts, and iframes that don’t set Cross-Origin-Resource-Policy. Analytics scripts, ad networks, social embeds — they often don’t set this header, so embedding them breaks your page. The credentialless value for COEP helps by stripping credentials from cross-origin subresource requests, making them loadable without CORP. There’s also a service worker library (coi-serviceworker) that automates adding COEP headers and stripping identifying information from cross-origin requests.
On the server side, setting the headers is straightforward. In Node.js with Express:
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
Or in Nginx:
add_header Cross-Origin-Opener-Policy same-origin;
add_header Cross-Origin-Embedder-Policy require-corp;
Atomics: Atomic Operations on Shared Memory
Atomics is a namespace — not a constructor. All its methods are static. It works on typed arrays that view a SharedArrayBuffer. The key guarantee: operations are atomic, meaning no torn reads or writes. A 32-bit write from one thread will never be observed as a half-written value by another thread.
Most atomic operations work on any integer typed array: Int8Array, Uint8Array, Int16Array, Uint16Array, Int32Array, Uint32Array, BigInt64Array, BigUint64Array. Wait and notify operations are restricted to Int32Array or BigInt64Array specifically.
Reading and Writing
const sab = new SharedArrayBuffer(8);
const ia = new Int32Array(sab);
Atomics.store(ia, 0, 99); // Returns 99 — the written value
Atomics.load(ia, 0); // 99 — guaranteed atomic read
Atomic Read-Modify-Write
These return the old value before the operation:
const sab = new SharedArrayBuffer(8);
const ta = new Uint8Array(sab);
ta[0] = 5;
Atomics.add(ta, 0, 10); // 5 — old value
Atomics.load(ta, 0); // 15 — new value
Atomics.sub(ta, 0, 3); // 15 — old value
Atomics.load(ta, 0); // 12 — new value
Bitwise operations are useful for flag management:
ta[0] = 0b1101; // 13
Atomics.and(ta, 0, 0b1010); // 13 — old value
Atomics.load(ta, 0); // 0b1000 = 8
Atomics.or(ta, 0, 0b0011); // 8 — old value
Atomics.load(ta, 0); // 0b1011 = 11
Compare-and-Exchange: The Foundation of Lock-Free Code
Atomics.compareExchange is the most powerful operation. It atomically does this: if the value at the index equals expectedValue, write replacementValue and return the old value. If it doesn’t match, do nothing and still return the old value. You can detect whether the exchange succeeded by checking whether the returned value equals your expectedValue.
const sab = new SharedArrayBuffer(4);
const ia = new Int32Array(sab);
ia[0] = 7;
const old = Atomics.compareExchange(ia, 0, 7, 12);
// old = 7 — matched, so 12 was written
console.log(Atomics.load(ia, 0)); // 12
const failed = Atomics.compareExchange(ia, 0, 7, 99);
// failed = 12 — didn't match (value was 12, not 7), nothing written
console.log(Atomics.load(ia, 0)); // still 12
This is compare-and-swap (CAS), and it lets you build lock-free data structures and atomic operations that retry on contention.
Thread Synchronization with Wait and Notify
Reading and writing shared memory is only half the problem. You often need threads to actually wait for each other — a worker shouldn’t read data before the main thread has written it. This is where Atomics.wait() and Atomics.notify() come in.
Atomics.wait() puts the current thread to sleep. It only sleeps if the value at the given index matches your expected value. If the value doesn’t match, it returns immediately with "not-equal". You can also pass a timeout in milliseconds.
Critically: Atomics.wait() blocks the thread, so it only works inside Web Workers — not on the main thread. The main thread is the event loop, and blocking it freezes your page. For the main thread, use waitAsync instead.
// In a worker
const sab = new SharedArrayBuffer(1024);
const status = new Int32Array(sab);
// Sleep until status[0] changes from 0
const result = Atomics.wait(status, 0, 0);
// result = "ok" if woken, "not-equal" if value already changed, "timed-out" if timeout expired
console.log('Value at index 0:', Atomics.load(status, 0));
Waking a sleeping thread:
// In main thread
Atomics.store(status, 0, 42); // Write the data FIRST
Atomics.notify(status, 0, 1); // Wake up to 1 waiting thread at index 0
// Returns: number of agents woken
The order matters. Write your data before calling notify. If you notify first, the woken thread might read stale data before your write completes.
Atomics.notify() wakes threads that are sleeping in wait() on that index. By default it wakes all of them; pass a count to wake just one or a few.
waitAsync: Non-Blocking Waiting
Atomics.waitAsync() is the main-thread-safe alternative. It doesn’t block — instead it returns a result object with an async flag and either a promise or a string value:
const result = Atomics.waitAsync(int32, 0, 0, 5000);
// result = { async: true, value: Promise }
result.value.then((v) => console.log(v)); // "ok" or "timed-out"
If async is false, the value is "not-equal" or "timed-out" synchronously. You can also check async to know whether to treat value as a promise.
Practical Patterns
Producer-Consumer Queue
This is the most common use case. The main thread produces data; a worker consumes it. They coordinate through a shared status value.
Worker (consumer):
const sab = new SharedArrayBuffer(1024);
const data = new Uint8Array(sab);
const status = new Int32Array(sab);
while (true) {
// Wait until main thread signals data is ready
Atomics.wait(status, 0, 0);
// Process data
const value = data[0];
console.log('Worker received:', value);
// Signal we're done — reset status so we can receive again
Atomics.store(status, 0, 0);
}
Main thread (producer):
const sab = new SharedArrayBuffer(1024);
const data = new Uint8Array(sab);
const status = new Int32Array(sab);
function sendToWorker(value) {
// Write data, then signal worker
data[0] = value;
Atomics.store(status, 0, 1); // Signal: data ready
Atomics.notify(status, 0, 1); // Wake the worker
}
Mutex with Spinlock + Wait
A mutual exclusion lock ensures only one thread holds access to a resource at a time. A naive spinlock just spins on a CAS loop — CPU-intensive but simple. A better approach combines fast spinning with wait for the slow path when contention is high.
class Mutex {
constructor(sab) {
this.lock = new Int32Array(sab);
}
acquire() {
let spinCount = 0;
const maxSpins = 100;
// Fast path: spin until we get the lock
while (spinCount < maxSpins) {
if (Atomics.compareExchange(this.lock, 0, 0, 1) === 0) {
return; // Got it
}
Atomics.pause();
spinCount++;
}
// Slow path: block until notified
Atomics.wait(this.lock, 0, 1);
}
release() {
Atomics.store(this.lock, 0, 0);
Atomics.notify(this.lock, 0, 1); // Wake one waiter
}
}
Atomics.pause() gives the CPU a hint that we’re spin-waiting, letting it reduce power and execution resources on that core. Atomics.isLockFree(size) tells you whether hardware atomic instructions are available for a given byte size — on modern hardware, all standard integer sizes (1, 2, 4, 8 bytes) are lock-free.
Atomic Counter
An increment that works safely across threads, using a CAS loop to handle contention:
const sab = new SharedArrayBuffer(8);
const counter = new Uint32Array(sab);
function atomicIncrement() {
let current;
do {
current = Atomics.load(counter, 0);
} while (Atomics.compareExchange(counter, 0, current, current + 1) !== current);
return current + 1;
}
The loop retries if another thread modified the value between our load and our CAS. This is lock-free — no mutex, no thread blocking.
Browser Support and Node.js
SharedArrayBuffer and Atomics have been available in Chrome, Firefox, Safari, and Edge since version 92 (2021). The waitAsync() and pause() methods landed in Firefox 89 and Safari 15.4. The grow() method and growable property are newer (Chrome/Edge 111+).
Without cross-origin isolation headers, SharedArrayBuffer may be partially or fully disabled depending on the browser and version. Always check self.crossOriginIsolated before relying on it.
In Node.js, both APIs work without any headers. The cross-origin isolation requirement is a browser security mechanism against Spectre — Node.js has no browser context to attack, so the headers make no sense there. Import from worker_threads:
const { SharedArrayBuffer, Atomics } = require('worker_threads');
// Works immediately — no headers needed
WebAssembly Integration
WebAssembly.Memory can be created with shared: true, which makes its buffer a SharedArrayBuffer:
const memory = new WebAssembly.Memory({ initial: 1, shared: true, maximum: 2 });
// memory.buffer is a SharedArrayBuffer
WebAssembly’s atomic instructions (from the Threads proposal) interoperate with JavaScript’s Atomics API. The same cross-origin isolation rules apply — WebAssembly shared memory won’t work without the required headers.
See Also
- The Event Loop — understanding the execution model that
SharedArrayBufferbypasses - Web Workers — creating worker threads that can share
SharedArrayBuffer - Structured Clone — how
postMessagetransfers objects between contexts - ArrayBuffer Reference — the non-shared sibling API