JavaScript Atomics & SharedArrayBuffer Table
| Piece | What it does | Field note |
|---|---|---|
SharedArrayBuffer | The shared memory | Bytes visible to every worker it is posted to - shared, not copied; undefined without cross-origin isolation |
Atomics.add/exchange/... | The RMW ops | Read-modify-write as one CPU instruction - i++ across threads loses updates, Atomics.add does not |
Atomics.load/store | The visibility | Non-atomic reads may be cached and never see the write - if another thread writes it, access it atomically |
Atomics.wait/notify | The futex | Sleep a worker until another thread notifies - no polling loop; throws on the main thread by design |
compareExchange | The CAS | The lock-free building block: swap only if untouched - optimistic concurrency in one call |
COOP/COEP headers | The gate | Spectre (2018) gated SAB behind crossOriginIsolated - headers at the server, bug looks like a JS mystery |
transfer vs share | The sibling | postMessage neuters regular ArrayBuffers; only SAB is same-memory - structured clone copies the rest |
when not to | The honest scope | Wasm threads, big numeric loops, live crypto buffers - occasional small updates want a message queue |
Atomics are the only JavaScript operations that share memory across threads for real. A SharedArrayBuffer is a byte array whose underlying memory is visible to every worker you post it to - not copied, shared - and the Atomics namespace provides the access discipline: read-modify-write operations that execute as single uninterruptible CPU instructions, plus wait/notify, a futex mechanism for sleeping threads until another thread changes a value.
Bottom line: the gate comes before the API. Since Spectre (2018), browsers hand out SharedArrayBuffer only to cross-origin-isolated pages - your document needs the COOP: same-origin and COEP: require-corp headers, or new SharedArrayBuffer is simply undefined. Teams meet this as a mystery bug ('works locally, undefined in production') because the headers live at the server, far from the JavaScript that misses the buffer. Check crossOriginIsolated in the console before debugging anything else.
The honest part: most 'shared state' problems want a message queue, not shared memory. Atomics shine where parallelism is data-shaped - WebAssembly threads, SIMD-ish numeric loops, a crypto worker hashing a live buffer - and they cost you the discipline that comes with them: spin loops are still yours to avoid, visibility only extends to atomic accesses, and the main thread can never call wait (the browser forbids freezing the UI thread). If your design is 'worker occasionally tells main something', postMessage already solved it, with copying you will not miss.
How to use
- Verify the gate first: crossOriginIsolated must be true, which needs both Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp response headers - and COEP then demands CORP/CORS headers on every cross-origin subresource (images, scripts), which is the real migration cost. Then new SharedArrayBuffer(1024) exists.
- Use the RMW ops for anything two threads touch: Atomics.add, sub, and, or, xor, exchange, compareExchange all read-and-write as one hardware operation. i++ across threads is lost updates waiting to happen; Atomics.add(ta, 0, 1) is a correct counter.
- Use load/store at the boundaries: Atomics.load(ta, i) and store() are the visible accesses; plain ta[i] reads between threads have no ordering guarantees you can reason about. Rule of thumb: if a location is ever written from another thread, every access to it is atomic.
- Coordinate with wait/notify: a worker calls Atomics.wait(ta, 0, expectedValue) - it sleeps until another thread Atomics.notify(ta, 0) wakes it, no polling loop. The catch that shapes architectures: wait throws on the main thread by design, so the main thread side stays event-driven while workers sleep.
- Know transfer versus share: postMessage with a regular ArrayBuffer transfers (the sender is neutered); postMessage with a SharedArrayBuffer shares (both sides see every byte). Structured clone copies plain data; only SAB is the 'same memory' primitive - and sync passkeys of state through messages where you can, shared cells where you must.
Frequently asked questions
Why is SharedArrayBuffer undefined in my production build?
The page is not cross-origin isolated. Since the Spectre mitigations, browsers only expose SharedArrayBuffer to documents that prove no foreign document shares their process: COOP: same-origin keeps other origins' windows out, COEP: require-corp keeps unapproved cross-origin resources in. Open the console and check crossOriginIsolated - it will say false. The fix is headers (plus CORP/CORS on every embedded third-party resource, which is the part that bites: fonts, analytics, CDN images all need cooperation). SharedArrayBuffer defined locally but undefined deployed usually means the production host strips or never sends the headers.
What does Atomics.wait on the main thread actually do?
It throws - deliberately. Waiting parks a thread until notify; parking the main thread would freeze the page's event loop, exactly what the browser exists to prevent. The architecture consequence: workers are the side that sleeps (they have nothing else to do), and the main thread coordinates through message events and notify() calls - it can wake workers, never wait on them. The classic shape: main posts work, worker processes shared memory, worker waits on a cell, main writes a value and notifies. Any design where the main thread 'waits for the worker to finish' should be a promise from postMessage instead, not Atomics.
Do I still need Atomics if JavaScript is single-threaded per context?
Yes - single-threaded per context is exactly the problem: two contexts (main + worker) run concurrently against shared SAB bytes, and a non-atomic read-modify-write compiles to load, add, store - three steps another thread can interleave. Atomics collapse that into one CPU instruction the interleaving cannot split. The subtler half is visibility: without atomic accesses, the engine's optimizer may cache a value in a register and never re-read it from shared memory, so a write from the worker is 'lost' forever. Atomic load/store forbids that caching. Both halves matter; the counter bug is just the easier one to picture.
When should I share memory instead of passing messages?
When the data is big and the access is fine-grained. A 500MB point cloud copied per frame through postMessage is a garbage-collector crime scene; the same buffer as SAB with workers writing their own slices is the design that works - this is why WebAssembly threads, video decoders, and physics engines reach for it. The threshold heuristics: kilobytes of occasional updates = postMessage, obviously; megabytes touched continuously = SAB; and anything in between is a judgment call you should settle by measuring the structured-clone cost, not by aesthetics. Shared memory buys throughput with coordination debt - atomics discipline, futex bookkeeping, and a debugging story where print statements race each other.