SharedArrayBuffer & Atomics

A Web Worker talks to the main thread by copying data. You call postMessage(obj), the structured clone algorithm walks obj, serializes it, and the worker gets its own fresh copy. Send a 200MB array and you’ve just allocated 400MB and paid to duplicate every byte. Transferables help — you can hand off an ArrayBuffer with zero copy — but transfer means the sender loses it. After the handoff only one side owns the memory.

Sometimes you want the opposite: one region of memory that several threads see at the same time, live, no copying, no handoff. Thread A writes byte 7 and thread B reads the new value a nanosecond later. That is what SharedArrayBuffer gives you — and it is exactly as sharp as it sounds. Two threads scribbling on the same bytes is the oldest bug in systems programming. The Atomics object exists to make those writes safe.

This is the most advanced concurrency tool the platform offers, and the easiest to get subtly wrong. We’ll build it up carefully.

Shared memory vs the copy model

A regular ArrayBuffer is owned by exactly one agent — one thread. A SharedArrayBuffer (SAB) is backed by memory that can be mapped into several agents at once. When you post an SAB to a worker, the worker doesn’t get a copy of the bytes. It gets a second handle to the same bytes.

ArrayBuffer + postMessage (copy)mainbytes (A)workerbytes (A’)duplicatedSharedArrayBuffer (one block)mainview →workerview →sharedbytes
postMessage with a normal buffer gives each thread its own private copy. A SharedArrayBuffer gives every thread a handle to one shared block of memory.

You never read or write a buffer directly — a buffer is just raw bytes. You lay a typed-array view over it, exactly like with a normal ArrayBuffer:

// main.js
const sab = new SharedArrayBuffer(1024);   // 1 KiB of shared memory
const shared = new Int32Array(sab);        // view: 256 int32 slots

const worker = new Worker("worker.js");
worker.postMessage(sab);   // hand the worker a second handle — NOT a copy

shared[0] = 42;            // worker can observe this
// worker.js
self.onmessage = ({ data: sab }) => {
  const shared = new Int32Array(sab);  // same underlying bytes
  console.log(shared[0]);              // 42 (eventually — see below)
};

Notice postMessage(sab) with no transfer list. You’re not transferring; both sides keep a working handle afterward. That’s the whole point. A quick contrast:

The security gate: cross-origin isolation

There’s a catch you hit before any of this runs. On most pages, SharedArrayBuffer either doesn’t exist or throws when you post it. That’s not a bug — it’s a deliberate security wall, and it’s worth understanding why.

In 2018 the Spectre class of CPU attacks showed that a script could infer the contents of memory it wasn’t supposed to read, by timing speculative-execution side effects. A high-resolution, shared-memory-based timer makes those attacks dramatically easier. SharedArrayBuffer plus a busy-loop is exactly such a timer. Browsers responded by disabling shared memory almost everywhere until a page can prove it isn’t sitting next to untrusted cross-origin content in the same process.

The proof is cross-origin isolation, and you opt in with two response headers on your top-level document:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
COOP:same-originno shared process withcross-origin openersCOEP:require-corpevery subresource mustopt in (CORP/CORS)crossOriginIsolated=== trueSharedArrayBufferAtomics.wait ✓
Both headers must be present. COOP isolates your browsing context group; COEP requires every subresource to opt in. Only then does the page become crossOriginIsolated and SharedArrayBuffer unlock.

Once both headers are set, check the result at runtime — it’s exposed on both the window and inside workers:

if (globalThis.crossOriginIsolated) {
  const sab = new SharedArrayBuffer(1024); // safe to use
} else {
  // fall back to a copy-based approach
}

As of mid-2026, SharedArrayBuffer, Atomics, and cross-origin isolation are Baseline widely available — every current engine has shipped them since around 2021 — but only on a cross-origin-isolated page. The API is mature; the gate in front of it is the part teams underestimate.

Why plain writes race

Here’s the trap. Suppose two workers both run counter[0]++ a thousand times each on a shared Int32Array. You’d expect the final value to be 2000. Run it and you’ll get something smaller — and different every time.

counter[0]++ is not one step. It’s three: read the current value into a register, add one, write it back. Nothing stops the other thread from reading the same old value in between your read and your write. Both compute “5 + 1 = 6”, both store 6, and one increment silently vanishes. This is a data race, and it is the defining hazard of shared memory.

worker Aworker Bshared[0]read → 5read → 55 + 1 = 65 + 1 = 6write 6write 6 ← lost!
A lost update. Both threads read 5 before either writes back, so two increments produce a final value of 6 instead of 7.

There’s a second, subtler problem: even a single shared[0] = 1 isn’t guaranteed to become visible to another thread on any particular schedule. Compilers and CPUs reorder and cache ordinary memory operations aggressively. Without a synchronization primitive, a plain read might see a stale value indefinitely. You need something that both makes the operation indivisible and establishes a happens-before ordering across threads.

Atomics: indivisible operations

The Atomics object is a namespace of static methods (you never construct it) that operate on an integer typed-array view over a SharedArrayBuffer. Each one performs its read-modify-write as a single, uninterruptible step, and each establishes memory ordering so other threads see a consistent picture.

Replace counter[0]++ with:

Atomics.add(counter, 0, 1);   // read, add 1, write — atomically

Now the two-worker experiment reliably totals 2000. The read-add-write can’t be split, so no update is lost.

worker Aworker BAtomics.add(c,0,1) 5→66Atomics.add(c,0,1) 6→77B’s operation waits for A’s to fully complete
Atomics.add serializes the read-modify-write. The second thread cannot observe an intermediate state, so both increments count.

The read/modify/write family all take the view and an index, and all return the old value (the value before the operation):

Atomics.load(view, i);                 // read i, ordered
Atomics.store(view, i, v);             // write v to i, returns v
Atomics.add(view, i, v);               // += v, returns old
Atomics.sub(view, i, v);               // -= v, returns old
Atomics.and(view, i, v);               // &= v, returns old
Atomics.or(view, i, v);                //  |= v, returns old
Atomics.xor(view, i, v);               // ^= v, returns old
Atomics.exchange(view, i, v);          // set to v, returns old
Atomics.compareExchange(view, i, expected, replacement); // returns old

compareExchange is the workhorse for building higher-level coordination. It writes replacement only if the current value equals expected, and it returns whatever was actually there. That “check-and-set in one atomic step” is how you build a lock, a one-shot flag, or a lock-free stack:

// Claim a spinlock: succeed only if the slot is currently 0 (unlocked).
function acquire(lock) {
  // returns 0 when we won the race and flipped 0 → 1
  while (Atomics.compareExchange(lock, 0, 0, 1) !== 0) {
    // someone else holds it — spin (in real code, back off or wait)
  }
}

function release(lock) {
  Atomics.store(lock, 0, 0);
}

Blocking and waking: wait / notify

Spinning in a while loop to wait for another thread burns a whole CPU core doing nothing. For real coordination you want a thread to sleep until it’s signalled. That’s Atomics.wait and Atomics.notify — a futex, in operating-system terms.

Atomics.wait(view, index, expected, timeout?) does an atomic check-then-sleep:

  1. It reads view[index]. If it does not equal expected, it returns "not-equal" immediately — the condition it was going to wait for already changed.
  2. If it does equal expected, the thread blocks — fully parked, zero CPU — until another thread calls Atomics.notify on the same slot, or until timeout milliseconds elapse.
  3. It returns "ok" (woken by a notify) or "timed-out".

Atomics.notify(view, index, count?) wakes up to count waiters parked on that slot (default: all of them) and returns how many it actually woke.

consumer workerproducer workerAtomics.wait(v, 0, 0)parked — 0% CPU💤 slot still === 0→ “ok”, resumeAtomics.store(v, 0, 1)Atomics.notify(v, 0)
A worker parks on Atomics.wait as long as the slot holds the expected value. Another thread stores a new value and calls Atomics.notify to wake it. The parked worker uses no CPU while waiting.

Here’s a minimal one-slot handshake — the consumer sleeps until the producer flips a flag:

// consumer.js  (inside a worker — wait() is illegal on the main thread)
const flag = new Int32Array(sab);
// Sleep while flag[0] is still 0. Wakes when producer stores 1 + notifies.
Atomics.wait(flag, 0, 0);
console.log("signalled, flag is now", Atomics.load(flag, 0));
// producer.js  (another worker)
const flag = new Int32Array(sab);
Atomics.store(flag, 0, 1);   // publish the new state first
Atomics.notify(flag, 0, 1);  // then wake one waiter

Order matters: store, then notify. If you notify before storing, the waiter could wake, re-check the slot, still see the old value, and park again — or worse, miss the signal. Always publish the state the waiter is checking for before you wake it.

Atomics.waitAsync(view, index, expected, timeout?) is the non-blocking sibling. Instead of parking the thread, it returns a plain object describing the outcome, and when it would block, it hands you a promise you can await:

// Safe on the main thread — never blocks the UI.
const result = Atomics.waitAsync(flag, 0, 0);
// result.async === true means we're waiting; result.value is a Promise
if (result.async) {
  result.value.then((status) => {
    // status is "ok" or "timed-out"
    console.log("woken:", status);
  });
} else {
  // result.value is "not-equal" — the value already changed, no wait needed
}

This plugs shared-memory signalling into the ordinary event loop: the promise resolves as a microtask, so it composes with async/await and keeps the page responsive. waitAsync reached Baseline more recently than the rest of the family (Safari was last, in 2023), so it’s solid on current browsers but check support if you target older ones.

SharedWorker: one worker, many pages

SharedArrayBuffer shares memory between threads. SharedWorker shares a whole worker between browsing contexts — multiple tabs, windows, or iframes from the same origin all talk to one single worker instance. Open your app in three tabs and there’s still just one SharedWorker behind them. It’s the natural home for a single WebSocket connection, a shared cache, or cross-tab coordination.

The connection model is different from a dedicated worker. You don’t get onmessage directly; you communicate through a MessagePort, because the worker may be serving many clients at once and needs to tell them apart.

tab 1tab 2tab 3one SharedWorkeronconnect → e.ports[0]holds all portsportportport
Three tabs from one origin share a single SharedWorker. Each connection arrives as a separate MessagePort inside the worker's connect event.

Each page connects and uses worker.port to send and receive:

// page.js — runs in every tab
const worker = new SharedWorker("shared.js", { name: "app-hub" });

worker.port.start();                       // required with addEventListener
worker.port.postMessage({ type: "hello" });
worker.port.addEventListener("message", (e) => {
  console.log("from shared worker:", e.data);
});

Inside the worker, a connect event fires once per page that connects, delivering that page’s port in e.ports[0]:

// shared.js — the single shared worker
const clients = [];

self.onconnect = (e) => {
  const port = e.ports[0];
  clients.push(port);

  port.onmessage = (msg) => {
    // broadcast to every connected tab
    for (const c of clients) c.postMessage({ echo: msg.data });
  };

  port.start();
};

Keep a list of ports and you can broadcast across tabs, deduplicate a network subscription, or serve a single source of truth. SharedWorker has been supported in Chrome and Firefox for years; Safari re-added support and it crossed into Baseline widely available in 2026, so it’s finally usable without heavy caveats — though a service worker or BroadcastChannel is often the simpler tool for plain cross-tab messaging.

When not to reach for this

Shared memory is a specialist tool. Most apps that use a worker should stick with postMessage: it’s copy-based, so there are no data races to reason about, and structured clone is fast enough for the vast majority of payloads. Reach for SharedArrayBuffer and Atomics only when you have a concrete, measured reason:

  • WebAssembly threads. This is the number-one real use. Multithreaded Wasm (Emscripten pthreads, Rust wasm-bindgen-rayon) is built on SharedArrayBuffer, and the toolchain generates the atomic operations for you. If you’re shipping a threaded Wasm module, you’re already here.
  • Tight, high-frequency numeric exchange. Audio processing, physics, real-time signal work — cases where copying a large buffer every frame is the measured bottleneck.
  • A ring buffer between a producer and consumer worker that would otherwise thrash the message queue.

If your data is objects, strings, or anything you’d have to hand-encode into bytes, that friction is a sign you want messages, not shared memory. And if you can’t turn on cross-origin isolation without breaking your third-party embeds, the decision is made for you.

Summary

  • A SharedArrayBuffer is memory mapped into several threads at once. Unlike postMessage’s copy or transfer, every holder reads and writes the same bytes live — you lay a numeric typed-array view over it to access it.
  • It only works on a cross-origin-isolated page: send Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, then confirm crossOriginIsolated === true. This wall exists because shared memory makes Spectre-style attacks easier.
  • Plain view[i]++ on shared memory races — the read-modify-write can interleave and lose updates. Atomics.add, compareExchange, exchange, load, store, and the bitwise ops make each operation indivisible and correctly ordered. The read-modify-write ops return the old value.
  • Atomics.wait parks a worker (zero CPU) until Atomics.notify wakes it — a futex for thread coordination. Always store the new state before notifying. wait throws on the main thread; use Atomics.waitAsync there for a promise-based, non-blocking wait.
  • SharedWorker shares one worker instance across tabs/windows of an origin, connecting through MessagePorts in its connect event. It shares the worker, not memory.
  • SharedArrayBuffer/Atomics are Baseline widely available (behind isolation) and the backbone of multithreaded WebAssembly — but for everyday app code, message passing is simpler and safer. Reach for shared memory only with a measured reason.