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.
Table of Contents
- 1. Why shared memory in JavaScript?
- 2. SharedArrayBuffer: sharing memory between threads
- 3. Security requirements: COOP and COEP headers
- 4. Atomics: atomic operations without race conditions
- 5. Atomics.wait() and Atomics.notify(), thread synchronization
- 6. Mutex implementation with Atomics.compareExchange()
- 7. Atomics and WebAssembly: WASM integration
- 8. Practical use cases in the frontend
- 9. Atomics vs. postMessage: comparison
- 10. Summary
- 11. FAQ
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.