jsguides

SharedArrayBuffer and Atomics: Shared Memory in JavaScript

JavaScript runs on a single thread. Well, 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 question. 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 will 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, so 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
};

Both the main thread and the worker now share the same underlying memory. A write from one thread is visible to the other immediately, with no copy or transfer step needed after the initial postMessage. Construct a SharedArrayBuffer the same way you would construct a regular ArrayBuffer, and you can also create a growable variant that expands up to a declared maximum:

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 to prevent information leaks between threads. You access it through typed arrays, just like a regular ArrayBuffer. Multiple typed array views can overlay the same buffer, each interpreting the bytes as a different integer type:

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 do not always 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');

For Nginx, add the same headers in your server block or location block:

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

Both add and sub return the value before the operation, so you can check what was there before modifying it. This pattern is useful for counters where you need to know the previous count. For flag and permission management, bitwise operations let you set, clear, and toggle individual bits atomically:

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 in the namespace. It performs an atomic check-and-set: if the value at the index equals expectedValue, it writes replacementValue and returns the old value. If the values do not match, nothing is written but the old value is still returned. Compare the return value to your expected value to know whether the exchange succeeded:

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 should not 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));

The worker sleeps until status[0] changes from 0 to something else, then logs the new value. To wake a sleeping worker from the main thread, write the new data first, then call Atomics.notify to wake exactly one waiting thread at that index:

// 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 does not 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
}

The main thread writes the data byte, then signals the worker by storing 1 into the status array and calling notify. The worker wakes up, reads the value, processes it, and resets the status back to 0 so it can wait for the next batch. This pattern works well for simple one-to-one coordination. For shared resources accessed by multiple threads simultaneously, you need a mutex.

Mutex with spinlock and wait

A mutual exclusion lock ensures only one thread holds access to a resource at a time. A naive spinlock spins on a CAS loop continuously, burning CPU. A better approach combines fast spinning for low contention with wait for the slow path when another thread already holds the lock:

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, the worker_threads module exposes both SharedArrayBuffer and Atomics directly without any header configuration. The cross-origin isolation requirement is purely a browser security mechanism against Spectre.

WebAssembly Integration

WebAssembly.Memory can be created with shared: true, which makes its buffer a SharedArrayBuffer accessible from both JavaScript and compiled WebAssembly code:

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, so WebAssembly shared memory won’t work without the required headers.

See Also