SharedArrayBuffer & Atomics: real shared memory

Almost everyone meets SharedArrayBuffer the same way: a tutorial says to use it, you paste the code, and the console says Uncaught ReferenceError: SharedArrayBuffer is not defined. Nothing is wrong with your JavaScript. The constructor is deliberately hidden until your page proves it is safe to hand out shared memory. This is a practical guide to that proof, and to the API you get once you have it: one block of memory that two threads can read and write with no copying, plus Atomics to keep them from tripping over each other.

Why it's hidden in the first place

Shared memory plus a busy loop is an extremely precise clock. Precise clocks are the raw ingredient of Spectre-style side-channel attacks: measure how long a read takes and you can infer what else is in the same process — including data from another origin. So in early 2018 browsers disabled SharedArrayBuffer and reduced timer resolution across the board.

The fix that landed instead of a permanent ban is cross-origin isolation. If your document can demonstrate it is not sharing a process with untrusted cross-origin content, the dangerous capability comes back. Chrome enforced this from version 92, and the same model now gates other powerful features such as performance.measureUserAgentSpecificMemory() and high-resolution timers.

Turning isolation on

The classic recipe is two response headers on the document (not on the script, not on the worker):

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

COOP severs the window-opener relationship with other top-level documents, so your page gets its own browsing context group. COEP declares that every subresource must explicitly opt in to being loaded by you. With both present the browser flips a switch you can read from code:

console.log(self.crossOriginIsolated) // must be true

That one line is the whole debugging story. If it logs false, stop editing your worker and go look at response headers in DevTools' Network panel.

document response: COOP: same-origin + COEP: require-corp → crossOriginIsolated === true main thread new SharedArrayBuffer(n) worker same bytes, no copy shared memory Int32Array / Float64Array view postMessage(buffer) — handle only Atomics.store / load / add · wait (worker) · waitAsync (anywhere)
postMessage still hands over the buffer — but it copies nothing.

The mistake almost everyone makes: CORP is not COEP

Three headers, similar names, completely different jobs:

  • COOP + COEP — set on your document. Together they switch isolation on.
  • CORP (Cross-Origin-Resource-Policy) — set by a subresource to say who may embed it. It is how a third-party image, font or script satisfies your COEP.

Adding Cross-Origin-Resource-Policy to your HTML page achieves exactly nothing for SharedArrayBuffer. And once COEP is on, any cross-origin asset that sends neither CORS nor CORP headers will be blocked — usually an analytics script, a CDN font or an embedded YouTube iframe. That's the real cost of isolation, and it's why so many teams give up halfway.

Two escape hatches make it survivable. Cross-Origin-Embedder-Policy: credentialless loads non-CORS resources anonymously, stripping cookies instead of blocking. And in Chromium 137+ there is Document-Isolation-Policy, which applies isolation per frame, imposes no requirements on subframes, and still lets you open cross-origin popups — with the same two modes, isolate-and-require-corp and isolate-and-credentialless. If you only need shared memory in one widget, that's a far smaller blast radius than COOP+COEP across a whole site.

Using it: views, not bytes

A SharedArrayBuffer is just raw bytes; you always work through a typed-array view. The important difference from ArrayBuffer is that it is not transferable — postMessage hands the receiver a second handle to the same memory rather than moving or cloning it:

const sab = new SharedArrayBuffer(1024 * Float64Array.BYTES_PER_ELEMENT);
const state = new Float64Array(sab);
worker.postMessage(sab); // no copy, no transfer list

On the worker side you wrap the received buffer in the same kind of view and write to it. Both agents now see each other's writes immediately. That is the whole appeal: for a physics or simulation loop pushing thousands of floats every frame, the per-frame cost of moving data drops to zero. When I wrote up offloading game physics to a Web Worker, the copy itself was never the bottleneck at small sizes — but the moment your buffer is measured in megabytes, structured clone shows up in the profile, and shared memory is the answer.

Atomics: the part you can't skip

Two threads writing the same memory means torn reads and lost updates. Atomics provides operations that are indivisible, and — just as importantly — that establish memory ordering so a value written before a flag is visible to whoever sees the flag:

  • Atomics.store(view, i, v) / Atomics.load(view, i) — ordered read and write.
  • Atomics.add, sub, and, or, xor, exchange — read-modify-write in one step. This is how you build a counter that doesn't lose increments.
  • Atomics.compareExchange(view, i, expected, next) — the primitive behind lock-free queues and simple mutexes.
  • Atomics.wait / Atomics.notify — park a thread until a slot changes, instead of burning CPU in a spin loop.

Atomics.wait only works on an Int32Array or BigInt64Array over shared memory, and it blocks — so it throws if you call it on the main thread. The main-thread-safe version is Atomics.waitAsync, which returns { async, value } where value is either the string "not-equal"/"timed-out" or a promise resolving to "ok" or "timed-out":

const r = Atomics.waitAsync(flags, 0, 0, 5000);
if (r.async) await r.value; // resolves when the worker calls Atomics.notify(flags, 0)

Support is now broad — Chrome and Edge 90+, Safari 16.4+, and Firefox from 145, which made it effectively baseline from late 2025. The pattern to internalise: the worker does the blocking waits, the main thread only ever waits asynchronously.

Where this actually matters

Be honest about the trade. Turning on isolation can break third-party embeds, and shared mutable state is the classic source of bugs that only appear on someone else's machine. Cases where it earns its keep:

  1. WebAssembly threads. A shared WebAssembly.Memory is backed by a SharedArrayBuffer, so anything compiled with pthreads — ffmpeg.wasm, SQLite's multi-threaded builds, Rust with rayon — requires isolation. No isolation, no threads: the module silently falls back to one thread and everything is slow.
  2. Large per-frame numeric state. Physics, particle systems, audio buffers, image pipelines: a worker writes positions, the render thread reads them.
  3. Low-latency signalling. An atomic flag plus notify beats a round of postMessage when you need a worker to react in microseconds.

Everything else — occasional messages, small payloads, one-off jobs — should stay on postMessage. It is simpler, it needs no headers, and transferable ArrayBuffers already avoid the copy for hand-offs. Shared memory is the tool for when the same block of numbers changes sixty times a second and both sides need to see it now.

Related
→ Offloading game physics to a Web Worker → Object pooling: killing GC stutter at 60 FPS → Portfolio & projects
Why is SharedArrayBuffer not defined?

Your page isn't cross-origin isolated. Since Chrome 92 the constructor is hidden unless the document response sends Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp (or Document-Isolation-Policy in Chromium 137+). Log self.crossOriginIsolated — if it's false, it's a headers problem, not a code problem.

What's the difference between COOP, COEP and CORP?

COOP and COEP go on your document and together enable isolation. CORP goes on a subresource to declare who may embed it, which is how third-party assets satisfy COEP. Setting CORP on your HTML page does nothing for shared memory.

Can I use Atomics.wait on the main thread?

No — it blocks the agent and throws on the main thread. Use Atomics.waitAsync, which returns a promise instead (Chrome/Edge 90+, Safari 16.4+, Firefox 145+). Let workers do the blocking waits.

Is SharedArrayBuffer faster than postMessage?

For big buffers updated every frame, yes — there's no copy and no structured clone. For small, occasional messages postMessage is simpler and fast enough, and transferable ArrayBuffers already avoid the copy.