Atomics and SharedArrayBuffer in JavaScript: True Parallelism in the Browser
AI generated
JS
() =>
JavaScript · Web Workers · Shared Memory · Concurrency
Atomics and SharedArrayBuffer
True Parallelism in JavaScript

JavaScript is single-threaded by nature, but with SharedArrayBuffer and Atomics there is true shared memory between threads. This solves scenarios that could not be handled efficiently with postMessage: fast image processing, parsers, WASM integration and lock-free data structures.

15 min read SharedArrayBuffer · Atomics · Web Workers · COOP/COEP Browser · Node.js · Deno

1. Why shared memory in JavaScript?

JavaScript was designed for single-threaded execution. The event loop processes one callback after another, without true parallelism. Web Workers extended this model: they run in separate threads with their own heap. Communication between workers and the main thread happens via postMessage, which serializes data, transfers it, and deserializes it on the receiving side. For small amounts of data that is sufficient, but for large binary data, images or continuous data streams, serialization becomes the bottleneck.

SharedArrayBuffer solves this problem fundamentally: instead of copying data between threads, all threads share the same memory region. A Web Worker can write directly into the same buffer that the main thread reads from, without copying, without serialization, without overhead. This enables scenarios that were previously not achievable in JavaScript: true lock-free data structures, efficient image pipelines, parsers that process large files without copy steps, and WASM modules that share memory with JavaScript code.

2. SharedArrayBuffer: sharing memory between threads

A SharedArrayBuffer is a raw memory object, similar to ArrayBuffer, but with a decisive difference: it can be handed to multiple workers without being copied. Where a normal ArrayBuffer is transferred via postMessage (the sender loses access), a SharedArrayBuffer can be read from and written to by any number of threads at the same time. The memory remains alive until all references to it have been released.

A SharedArrayBuffer is accessed through typed arrays: Int32Array, Uint8Array, Float64Array and others. These views point to the same memory region and provide efficient typed access. If one worker writes into the buffer via Int32Array, another worker sees this change immediately, without message passing, without delay. This is true shared-memory communication, as used by C++ and Java programmers for decades, now also available in JavaScript.


// Main thread: create shared buffer and send to worker
const sharedBuffer = new SharedArrayBuffer(4096); // 4 KB shared memory
const sharedArray = new Int32Array(sharedBuffer);

// Initialize with values
sharedArray[0] = 0; // status flag
sharedArray[1] = 0; // counter

const worker = new Worker('worker.js');

// Transfer reference (no copy, both share the same memory)
worker.postMessage({ sharedBuffer });

// Read from main thread (sees worker writes immediately)
setTimeout(() => {
  console.log('Counter from worker:', sharedArray[1]);
}, 1000);

// --- worker.js ---
self.onmessage = ({ data: { sharedBuffer } }) => {
  const sharedArray = new Int32Array(sharedBuffer);

  // Write directly to shared memory
  for (let i = 0; i < 1000; i++) {
    sharedArray[1]++; // increment shared counter
  }

  self.postMessage('done');
};

3. Security requirements: COOP and COEP headers

After the Spectre attack in 2018, SharedArrayBuffer was temporarily disabled in browsers. The reason: shared memory with high time resolution enables timing attacks that exploit CPU cache side channels. The solution was not to abolish SharedArrayBuffer, but to introduce site isolation requirements. Since 2020, SharedArrayBuffer is available again, but only in so-called "cross-origin isolated" contexts.

Concretely this means: the server must send two HTTP headers. Cross-Origin-Opener-Policy: same-origin (COOP) prevents other pages from referencing the browser window. Cross-Origin-Embedder-Policy: require-corp (COEP) ensures that all resources on the page explicitly allow cross-origin access. Both headers together activate the "cross-origin isolated" mode, which is required for SharedArrayBuffer and for high time resolution in performance.now(). Anyone who does not set these headers gets a SecurityError exception when creating a SharedArrayBuffer.

4. Atomics: atomic operations without race conditions

When multiple threads write to the same memory region at the same time, a race condition arises without synchronization. The classic problem: thread A reads a value (e.g. 5), thread B reads the same value (5), thread A writes 6 back, thread B also writes 6 back, so although both incremented once, the memory only holds 6, not 7. This is called a "lost update". The Atomics object in JavaScript solves this problem through atomic operations: read-modify-write in a single, indivisible step.

Atomics.add(array, index, value) increments the value at a position atomically, no other thread can interfere. Atomics.load(array, index) reads a value with guaranteed visibility of the most recent write by other threads. Atomics.store(array, index, value) writes a value that is immediately visible to all other threads. Atomics only works with Int8Array, Int16Array, Int32Array, BigInt64Array and their unsigned variants, all backed by a SharedArrayBuffer.


// Atomic operations (safe concurrent access without race conditions)
const sab = new SharedArrayBuffer(16);
const int32 = new Int32Array(sab);

// Atomic increment (returns OLD value, no lost updates)
const oldValue = Atomics.add(int32, 0, 1);
console.log('Was:', oldValue, 'Now:', int32[0]);

// Atomic compare-and-exchange: only writes if current value matches expected
// Returns the value BEFORE the exchange (whether it succeeded or not)
const wasExpected = Atomics.compareExchange(
  int32, // typed array
  0,     // index
  5,     // expected value (only swap if current === 5)
  10,    // replacement value
);

if (wasExpected === 5) {
  console.log('Exchange succeeded, now 10');
} else {
  console.log('Exchange failed, was', wasExpected, 'not 5');
}

// Atomic AND, OR, XOR for bit manipulation
Atomics.or(int32, 1, 0b00001000); // set bit 3 atomically
Atomics.and(int32, 1, 0b11110111); // clear bit 3 atomically

// Fence: ensure all prior stores are visible before continuing
Atomics.store(int32, 2, 1); // write sentinel value
// Any Atomics.load() after this on another thread will see the store above

5. Atomics.wait() and Atomics.notify(), thread synchronization

Atomics.wait() and Atomics.notify() implement the classic condvar pattern (condition variable) from low-level systems programming. A thread calls Atomics.wait(array, index, expectedValue) and blocks until the value at the given position is no longer expectedValue, or until a timeout expires. Another thread changes the value and calls Atomics.notify(array, index, count) to wake up waiting threads. This is the foundation for mutexes, semaphores and producer-consumer queues in JavaScript.

An important distinction: Atomics.wait() can only be called on worker threads, not on the browser's main thread (which must not block). On the main thread, since 2024 there is Atomics.waitAsync(), which returns a promise instead of blocking. In Node.js, Atomics.wait() is allowed on the main thread, because Node.js is designed differently for I/O-heavy workloads. Atomics.notify() on the other hand can be called from anywhere.

6. Mutex implementation with Atomics.compareExchange()

A mutex (mutual exclusion lock) is the most fundamental synchronization primitive for shared memory. With Atomics.compareExchange() a spinlock can be implemented: the lock value is 0 (unlocked) or 1 (locked). A thread tries to switch from 0 to 1 using compareExchange. If the return value is 0, it acquired the lock successfully. If not, the mutex was already locked and the thread has to wait. With Atomics.wait() it can wait more efficiently than with a busy-wait loop.

The critical section between lock and unlock is executed by at most one thread at a time, all others wait. When unlocking, the thread resets the value back to 0 and calls Atomics.notify() to wake up waiting threads. This pattern is the foundation for all higher-level synchronization primitives: semaphores, read-write locks and condition variables. In practice such structures are the basis for performant parsers and encoders that process large amounts of data in parallelized chunks.


// Mutex implementation using Atomics.compareExchange()
// Index 0 in the Int32Array is the lock: 0=unlocked, 1=locked
class SharedMutex {
  constructor(sab, byteOffset = 0) {
    this.lock = new Int32Array(sab, byteOffset, 1);
  }

  acquire() {
    // Spin until lock is acquired
    while (true) {
      // Try to change 0 → 1; returns old value
      const prev = Atomics.compareExchange(this.lock, 0, 0, 1);
      if (prev === 0) return; // acquired successfully

      // Lock was taken (wait until notified, efficient block)
      Atomics.wait(this.lock, 0, 1);
    }
  }

  release() {
    Atomics.store(this.lock, 0, 0); // release lock
    Atomics.notify(this.lock, 0, 1); // wake one waiting thread
  }
}

// Usage in worker:
self.onmessage = ({ data: { sab } }) => {
  const mutex = new SharedMutex(sab, 0);
  const counter = new Int32Array(sab, 4, 1); // counter at offset 4

  for (let i = 0; i < 10000; i++) {
    mutex.acquire();
    counter[0]++; // critical section (safe with mutex)
    mutex.release();
  }
};

7. Atomics and WebAssembly: WASM integration

One of the most important use cases of SharedArrayBuffer and Atomics is integration with WebAssembly. WASM modules compiled from C++, Rust or Go often expect shared memory, exactly what SharedArrayBuffer provides. Emscripten-compiled C++ code that uses POSIX threads internally uses SharedArrayBuffer for the thread stack and Atomics for pthread synchronization. Without these APIs, threading in WASM in the browser would not be possible.

For JavaScript developers this means: if you run a compute-heavy WASM module (e.g. an image filter, codec or physics simulator) in a worker and want to get results back efficiently, SharedArrayBuffer is the right channel. Instead of serializing the entire result and transferring it via postMessage, the WASM module writes directly into the shared buffer and the main thread reads the data immediately. For large buffers (e.g. a full HD video frame: 8 MB) this can save several milliseconds of latency.

8. Practical use cases in the frontend

The three most common use cases for SharedArrayBuffer and Atomics in the frontend are image processing, audio processing and structured parallel work. In image processing, multiple workers operate on different parts of an image at the same time, for example a 4K image is split into four horizontal strips, each worker processes one strip in the same SharedArrayBuffer, and the result is ready in the main thread without a copy step. This enables real-time filters that would be too slow without shared memory.

For audio, the Web Audio API recently gained the AudioWorkletProcessor, which runs on its own thread. With SharedArrayBuffer, an analysis worker can permanently read audio samples from the worklet thread without every sample having to be transferred via postMessage. This eliminates the jitter problems that arise with message-based audio streaming. As a third use case: JSON parsers and compression algorithms that process large inputs in parallel chunks, each worker takes a chunk from a shared input buffer and writes the result into a shared output buffer.

9. Atomics vs. postMessage: comparison

The choice between Atomics/SharedArrayBuffer and postMessage depends on the data volume and the communication frequency. For rare, small messages, postMessage is simpler and sufficient. For high-frequency communication or large amounts of data, shared memory is clearly superior. The basis for the decision lies in measurement: postMessage with a 1 MB ArrayBuffer typically costs 1-5 ms for serialization and transfer; the same access via SharedArrayBuffer costs nanoseconds.

Criterion postMessage SharedArrayBuffer + Atomics Recommendation
Serialization overhead O(n), structured clone None, direct access Shared memory above 10 KB
Synchronization Automatic (copy) Manual (Atomics) postMessage for simple cases
Setup complexity Low High (COOP/COEP headers) Shared memory only when needed
Latency at 1 MB 1 to 5 ms <1 µs Shared memory for large data
Debugging Simple Hard (race conditions) Use a mutex abstraction

The basic rule: SharedArrayBuffer and Atomics are tools for high-performance scenarios, not for general worker communication. They bring real complexity: race conditions, deadlock risks, COOP/COEP requirements, and should only be used when postMessage measurably becomes the bottleneck. In practice this is rarely the case, but when it is, there is no equivalent alternative in the browser ecosystem.

Mironsoft

JavaScript performance, Web Workers and WebAssembly integration

Implementing high-performance JavaScript with shared memory?

We help design and implement parallel JavaScript architectures with SharedArrayBuffer, Atomics and WebAssembly integration.

Performance audit

Analysis of whether SharedArrayBuffer actually solves the bottleneck or postMessage is enough

Architecture

Worker pool design, mutex abstractions and safe shared memory protocols

WASM integration

Connecting WebAssembly with SharedArrayBuffer and enabling threading for WASM modules

10. Summary

SharedArrayBuffer enables true shared memory between JavaScript threads, without serialization overhead, without postMessage overhead. Atomics provides the synchronization primitives that are necessary for safe concurrent access to this shared memory: atomic read/write operations, compare-and-exchange for lock-free algorithms, and wait/notify for efficient waiting. Together they enable parallel processing scenarios that were previously not achievable in the browser.

The entry barrier is deliberately high: COOP/COEP headers must be configured, race conditions must be understood and avoided, and the debugging experience is more complex than with postMessage. Atomics and SharedArrayBuffer are not a replacement for postMessage in normal worker scenarios, they are the right choice for high-performance scenarios with large amounts of data, WASM integration or compute-heavy image processing. Whoever masters these tools has access to a class of optimizations that is unique in the JavaScript ecosystem.

Atomics and SharedArrayBuffer, the essentials at a glance

SharedArrayBuffer

Shared raw memory between threads without copying overhead. Requirement: COOP + COEP HTTP headers for cross-origin isolation.

Atomics operations

add, sub, and, or, xor, load, store as atomic operations. compareExchange for lock-free algorithms and mutex implementations.

Thread synchronization

Atomics.wait() efficiently blocks worker threads. Atomics.notify() wakes waiting threads. waitAsync() for the non-blocking main thread.

When to use it

Only when postMessage measurably becomes the bottleneck. Image processing, audio worklets, WASM integration and large binary data are the right scenarios.

11. FAQ: Atomics and SharedArrayBuffer

1What is a SharedArrayBuffer?
Raw memory object shared between Web Workers and the main thread, without copying overhead. All threads see changes immediately. True shared memory in JavaScript.
2Why do you need COOP and COEP?
Spectre protection since 2020. Without Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp there is a SecurityError when creating the SharedArrayBuffer.
3What does Atomics.compareExchange() do?
Reads the current value, compares it to expected, writes replacement only if equal, all atomically. Returns the value BEFORE the operation. Foundation for mutex implementations.
4Atomics.wait() on the main thread?
Not in browsers, it would block the thread. Use Atomics.waitAsync() instead, which returns a promise. In Node.js, Atomics.wait() is allowed on the main thread.
5SharedArrayBuffer vs. transferable ArrayBuffer?
Transfer: sender loses access. SharedArrayBuffer: all threads point to the same memory at the same time. No transfer, no copy, no ownership change.
6What is a race condition?
Two threads read and write the same memory without synchronization. The result is not deterministic. Atomics and mutexes prevent race conditions.
7When SharedArrayBuffer instead of postMessage?
For large data (over 10 KB), high frequency (audio, video) or WASM threading. For normal worker coordination, postMessage is simpler and sufficient.
8What does Atomics.notify() do?
Wakes up to N threads that are waiting with Atomics.wait(). Counterpart to wait(). Implements the condvar pattern for thread coordination.
9SharedArrayBuffer in Node.js?
Fully supported. Worker threads in Node.js can share a SharedArrayBuffer. Atomics.wait() is allowed on the main thread. No COOP/COEP needed.
10Debugging race conditions?
Put mutexes around critical sections and check whether the problem disappears. Logging with Atomics.load() instead of direct array access. Reduce the code to minimal critical sections.