JavaScript Atomics and SharedArrayBuffer in 2026: Practical Patterns for Cross-Worker State
Lock-free ring buffers, shared task queues, and multi-worker synchronization patterns that eliminate postMessage serialization overhead in production JavaScript applications.
JavaScript Atomics and SharedArrayBuffer in 2026: Practical Patterns for Cross-Worker State
Most cross-worker communication problems stem from treating workers as isolated processes when the workload demands shared state. Teams reach for postMessage by default, serialize multi-megabyte data structures on every frame, and watch their real-time audio pipelines stutter under 100ms message latency. The browser gives developers true shared memory through SharedArrayBuffer, but production codebases rarely exploit it because the API surface feels foreign and the security requirements seem burdensome.
The failure mode here is subtle but expensive. A video processing pipeline that bounces 1920×1080 frames through postMessage spends 15-20ms per transfer just copying pixels. That overhead compounds across worker boundaries until the entire system misses its 16.67ms budget. Meanwhile, a SharedArrayBuffer-backed ring buffer eliminates the copy entirely and keeps the same workload under 2ms.
flowchart LR
A("Worker sends frame") --> B("postMessage serializes 8MB")
B --> C("Main thread blocks 15ms")
C --> D("Frame drops, user sees stutter")
style D stroke:#ef4444,fill:#450a0a,color:#fca5a5
The correct approach places pixel data in shared memory once, then coordinates access with atomic operations. Workers read and write the same underlying bytes without serialization. The synchronization primitives—Atomics.wait, Atomics.notify, compare-and-swap—replace message passing with lock-free coordination that runs in microseconds instead of milliseconds.
flowchart LR
A("Worker sends frame") --> B("Writes pointer to shared ring buffer")
B --> C("Atomics.notify wakes consumer")
C --> D("Frame renders in 2ms")
style D stroke:#34d399,fill:#0b3b2e,color:#d1fae5
This distinction is critical because the web platform now ships SharedArrayBuffer with reliable cross-origin isolation in every major browser. The security requirements that blocked adoption in 2018 are solved. Production teams that master these patterns unlock performance headroom that message passing cannot match.
Key Takeaways
SharedArrayBuffereliminates serialization overhead by giving workers direct access to the same memory, turning 15mspostMessagecopies into microsecond atomic operations for real-time workloads.Atomics.compareExchangeandAtomics.wait/notifyprovide lock-free coordination primitives that replace mutex-heavy approaches, enabling ring buffers and work queues without blocking.- Cross-origin isolation (COOP and COEP headers) is mandatory for
SharedArrayBuffersince 2020, but the security model is stable and shipping in all modern browsers as of 2026. - Shared memory outperforms message passing when transfer size exceeds ~100KB or when latency budgets are under 5ms; smaller payloads still favor
postMessageorTransferablefor simplicity. - Production patterns like lock-free ring buffers and shared task queues require careful synchronization to avoid data races, but the complexity pays off in audio processing, video encoding, and high-throughput analytics pipelines.
SharedArrayBuffer Fundamentals: Memory Model and Security Requirements
SharedArrayBuffer allocates a contiguous block of memory that multiple workers can map into their address spaces simultaneously. Unlike ArrayBuffer, which each worker owns exclusively, a SharedArrayBuffer instance lives until all references drop. This shared ownership means concurrent reads and writes from different threads can observe each other's mutations without explicit synchronization—unless developers use atomic operations to enforce ordering.
The memory model follows sequential consistency for atomic operations and relaxed ordering for plain reads and writes. In other words, Atomics.load and Atomics.store guarantee that all workers see updates in the same order, while non-atomic accesses can reorder freely. This matters because a worker writing buffer[0] = 1; buffer[1] = 2; might let another worker observe buffer[1] === 2 before buffer[0] === 1 due to CPU-level reordering. Atomic operations prevent this surprise.
Cross-origin isolation became mandatory for SharedArrayBuffer after the Spectre disclosure in 2018. Browsers require two response headers: Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. These headers ensure that the page cannot load cross-origin content without explicit consent, closing the timing side-channel that Spectre exploits. As of 2026, every major browser enforces this requirement consistently.
flowchart TD
A("Browser receives page") --> B{"COOP and COEP headers present?"}
B -- Yes --> C("SharedArrayBuffer enabled")
B -- No --> D("SharedArrayBuffer disabled, postMessage only")
C --> E("Workers share memory, atomic ops available")
D --> F("Workers isolated, transfers required")
style C stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style D stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
Setting up shared memory in practice looks like this:
// main.ts — Create shared buffer and pass to workers
const sharedBuffer = new SharedArrayBuffer(1024 * 1024); // 1MB
const view = new Uint32Array(sharedBuffer);
const worker1 = new Worker('worker.js');
const worker2 = new Worker('worker.js');
worker1.postMessage({ sharedBuffer }, []);
worker2.postMessage({ sharedBuffer }, []);
// worker.js — Both workers receive the same buffer
self.addEventListener('message', (event) => {
const { sharedBuffer } = event.data;
const view = new Uint32Array(sharedBuffer);
// Both workers can now read/write the same memory
Atomics.store(view, 0, 42);
console.log(Atomics.load(view, 0)); // 42 from either worker
});The implication here is that developers control when to share and when to transfer. A SharedArrayBuffer in the transfer list does nothing—it stays shared regardless. A regular ArrayBuffer in the transfer list moves ownership. This asymmetry lets teams mix both models: transfer large immutable blobs (images, video frames) and share small mutable state (cursors, flags, counters).
The security model imposes real deployment constraints. Any page serving third-party widgets or embedded iframes cannot use SharedArrayBuffer unless those resources opt in with CORP headers. For most applications, this means consolidating critical workers under the same origin and serving analytics or ads through separate, isolated contexts.
Atomic Operations: Beyond Simple Reads and Writes
Atomics.load and Atomics.store guarantee sequential consistency—every worker sees updates in a single global order. These operations prevent the CPU from reordering memory accesses across atomic boundaries, which matters when implementing lock-free algorithms. A simple counter incremented with Atomics.add(view, index, 1) never loses updates even when ten workers increment concurrently.
The operation set includes arithmetic (add, sub, and, or, xor), compare-and-swap (compareExchange), and synchronization primitives (wait, notify). Compare-and-swap is the foundation for lock-free structures: it reads a value, compares it to an expected value, and swaps in a new value only if the comparison matches—all in one atomic step. This enables patterns like lock-free stacks and queues.
Atomics.wait blocks the calling worker until another worker calls Atomics.notify on the same memory location, or until a timeout expires. This primitive replaces busy-waiting spin loops with efficient parking. A worker waiting for data can suspend immediately instead of burning CPU cycles checking a flag. The implication here is that wait only works in workers—the main thread cannot block because that would freeze the UI.
// Producer worker — Writes data and signals consumers
function produceData(sharedView: Int32Array, dataIndex: number, flagIndex: number) {
// Write actual data
Atomics.store(sharedView, dataIndex, computeExpensiveValue());
// Set ready flag
Atomics.store(sharedView, flagIndex, 1);
// Wake one waiting consumer
Atomics.notify(sharedView, flagIndex, 1);
}
// Consumer worker — Waits for data to be ready
function consumeData(sharedView: Int32Array, dataIndex: number, flagIndex: number) {
// Wait until flag becomes 1 (timeout after 1000ms)
const result = Atomics.wait(sharedView, flagIndex, 0, 1000);
if (result === 'ok') {
const data = Atomics.load(sharedView, dataIndex);
processData(data);
// Reset flag for next round
Atomics.store(sharedView, flagIndex, 0);
} else if (result === 'timed-out') {
console.warn('Producer did not signal within timeout');
}
}The wait return value encodes three states: 'ok' (woken by notify), 'not-equal' (value changed before wait started), or 'timed-out'. This matters because a producer might signal before the consumer calls wait, causing a missed wakeup. Robust patterns check the flag value first, then only wait if the flag still indicates "not ready."
Compare-and-swap enables ownership transfer without locks. A task queue can use CAS to claim work items:
// Lock-free task claiming
function claimTask(sharedView: Int32Array, taskIndex: number): boolean {
const UNCLAIMED = 0;
const CLAIMED = 1;
// Try to swap UNCLAIMED -> CLAIMED atomically
const previous = Atomics.compareExchange(sharedView, taskIndex, UNCLAIMED, CLAIMED);
// If previous was UNCLAIMED, we won the race
return previous === UNCLAIMED;
}
// Worker loop
while (true) {
for (let i = 0; i < taskCount; i++) {
if (claimTask(sharedTasks, i)) {
executeTask(i);
Atomics.store(sharedTasks, i, 0); // Release back to pool
}
}
}This distinction is critical because CAS-based algorithms scale better than mutex-based ones under contention. When ten workers compete for tasks, CAS lets them retry instantly on failure instead of blocking in a queue. The tradeoff is complexity: CAS loops require careful handling of the ABA problem (a value changes from A to B and back to A, fooling CAS into thinking nothing changed).
Pattern 1: Lock-Free Ring Buffer for Real-Time Audio
Real-time audio processing demands predictable latency under 5ms. A ring buffer backed by SharedArrayBuffer lets an audio worklet write samples directly into shared memory while a processing worker consumes them without blocking. The pattern uses two atomic indices—read and write—to coordinate without locks.
// Shared ring buffer structure
interface RingBufferState {
buffer: Float32Array; // Audio samples
writeIndex: Int32Array; // [writePos, readPos]
capacity: number;
}
class LockFreeRingBuffer {
private buffer: Float32Array;
private indices: Int32Array;
private capacity: number;
constructor(sharedBuffer: SharedArrayBuffer, sampleCount: number) {
// First 8 bytes for indices, rest for audio samples
this.indices = new Int32Array(sharedBuffer, 0, 2);
this.buffer = new Float32Array(
sharedBuffer,
8,
sampleCount
);
this.capacity = sampleCount;
Atomics.store(this.indices, 0, 0); // writeIndex
Atomics.store(this.indices, 1, 0); // readIndex
}
write(samples: Float32Array): boolean {
const writePos = Atomics.load(this.indices, 0);
const readPos = Atomics.load(this.indices, 1);
// Calculate available space
const available = (readPos - writePos - 1 + this.capacity) % this.capacity;
if (available < samples.length) {
return false; // Buffer full, drop samples
}
// Write samples in two chunks if wrapping
const firstChunkSize = Math.min(
samples.length,
this.capacity - writePos
);
this.buffer.set(
samples.subarray(0, firstChunkSize),
writePos
);
if (firstChunkSize < samples.length) {
this.buffer.set(
samples.subarray(firstChunkSize),
0
);
}
// Advance write index atomically
const newWrite = (writePos + samples.length) % this.capacity;
Atomics.store(this.indices, 0, newWrite);
return true;
}
read(output: Float32Array): number {
const writePos = Atomics.load(this.indices, 0);
const readPos = Atomics.load(this.indices, 1);
// Calculate available samples
const available = (writePos - readPos + this.capacity) % this.capacity;
const toRead = Math.min(available, output.length);
if (toRead === 0) {
return 0; // Buffer empty
}
// Read samples in two chunks if wrapping
const firstChunkSize = Math.min(
toRead,
this.capacity - readPos
);
output.set(
this.buffer.subarray(readPos, readPos + firstChunkSize),
0
);
if (firstChunkSize < toRead) {
output.set(
this.buffer.subarray(0, toRead - firstChunkSize),
firstChunkSize
);
}
// Advance read index atomically
const newRead = (readPos + toRead) % this.capacity;
Atomics.store(this.indices, 1, newRead);
return toRead;
}
}
// Audio worklet (producer)
class SharedBufferProcessor extends AudioWorkletProcessor {
private ringBuffer: LockFreeRingBuffer;
process(inputs: Float32Array[][], outputs: Float32Array[][]) {
const input = inputs[0][0]; // Mono channel
if (!this.ringBuffer.write(input)) {
console.warn('Ring buffer overflow, dropping samples');
}
return true;
}
}
// Processing worker (consumer)
const processingBuffer = new Float32Array(128);
function processAudioLoop(ringBuffer: LockFreeRingBuffer) {
const samplesRead = ringBuffer.read(processingBuffer);
if (samplesRead > 0) {
// Apply effects, analysis, etc.
applyReverb(processingBuffer.subarray(0, samplesRead));
}
// Loop without blocking
setTimeout(() => processAudioLoop(ringBuffer), 1);
}The ring buffer eliminates serialization overhead entirely. An audio worklet running at 48kHz generates 128 samples every 2.67ms. Copying those samples through postMessage adds 0.5-1ms of latency per message. With shared memory, the write operation completes in under 10 microseconds—two orders of magnitude faster.
The failure mode here is buffer overflow when the producer outpaces the consumer. The pattern handles this gracefully by dropping samples instead of blocking the audio thread. Production systems monitor the drop rate and adjust buffer size dynamically: a sustained 5% drop rate triggers a capacity increase from 4096 to 8192 samples.
This matters because real-time audio is the canonical use case for shared memory. Message passing fundamentally cannot meet the latency budget. Teams building DAWs, live streaming encoders, or voice chat systems reach for SharedArrayBuffer first and accept the complexity trade.
Pattern 2: Worker Pool with Shared Task Queue
A worker pool pattern distributes tasks across multiple workers without a central coordinator. Tasks live in a shared queue, and workers claim them with compare-and-swap. This eliminates the bottleneck of routing work through the main thread and scales linearly with worker count.
// Shared task queue structure
const TASK_UNCLAIMED = 0;
const TASK_CLAIMED = 1;
const TASK_COMPLETE = 2;
interface TaskQueueLayout {
// Task states: [state0, state1, ..., stateN]
states: Int32Array;
// Task payloads: [task0_data, task1_data, ...]
payloads: Float64Array;
taskCount: number;
}
class SharedTaskQueue {
private states: Int32Array;
private payloads: Float64Array;
private taskCount: number;
constructor(sharedBuffer: SharedArrayBuffer, taskCount: number) {
this.taskCount = taskCount;
this.states = new Int32Array(sharedBuffer, 0, taskCount);
this.payloads = new Float64Array(
sharedBuffer,
taskCount * 4, // After states
taskCount
);
}
enqueueTask(taskId: number, payload: number): void {
Atomics.store(this.payloads, taskId, payload);
Atomics.store(this.states, taskId, TASK_UNCLAIMED);
}
claimTask(): number | null {
// Linear scan for unclaimed task
for (let i = 0; i < this.taskCount; i++) {
const previous = Atomics.compareExchange(
this.states,
i,
TASK_UNCLAIMED,
TASK_CLAIMED
);
if (previous === TASK_UNCLAIMED) {
return i; // Successfully claimed
}
}
return null; // No tasks available
}
getTaskPayload(taskId: number): number {
return Atomics.load(this.payloads, taskId);
}
completeTask(taskId: number, result: number): void {
Atomics.store(this.payloads, taskId, result);
Atomics.store(this.states, taskId, TASK_COMPLETE);
}
waitForCompletion(timeout: number = 5000): boolean {
const start = Date.now();
while (Date.now() - start < timeout) {
let allComplete = true;
for (let i = 0; i < this.taskCount; i++) {
const state = Atomics.load(this.states, i);
if (state !== TASK_COMPLETE) {
allComplete = false;
break;
}
}
if (allComplete) return true;
// Yield to avoid busy-wait
Atomics.wait(this.states, 0, TASK_COMPLETE, 10);
}
return false;
}
}
// Worker code
function workerLoop(queue: SharedTaskQueue) {
const taskId = queue.claimTask();
if (taskId !== null) {
const input = queue.getTaskPayload(taskId);
const result = performComputation(input);
queue.completeTask(taskId, result);
} else {
// No work available, yield briefly
setTimeout(() => workerLoop(queue), 5);
return;
}
// Continue processing
setTimeout(() => workerLoop(queue), 0);
}
// Main thread usage
const bufferSize = 1024 * 1024; // 1MB
const taskCount = 1000;
const sharedBuffer = new SharedArrayBuffer(bufferSize);
const queue = new SharedTaskQueue(sharedBuffer, taskCount);
// Spawn workers
const workers = Array.from({ length: 8 }, () => {
const worker = new Worker('worker.js');
worker.postMessage({ sharedBuffer, taskCount });
return worker;
});
// Enqueue tasks
for (let i = 0; i < taskCount; i++) {
queue.enqueueTask(i, Math.random() * 1000);
}
// Wait for completion
if (queue.waitForCompletion(10000)) {
console.log('All tasks complete');
// Collect results
const results = Array.from({ length: taskCount }, (_, i) =>
queue.getTaskPayload(i)
);
}This pattern shines when task execution time varies widely. A message-passing coordinator must track which workers are idle and route tasks accordingly. The shared queue eliminates that bookkeeping—workers self-schedule by claiming whatever task they find first.
The linear scan for unclaimed tasks looks expensive, but in practice it completes in under 50 microseconds for queues up to 10,000 tasks. The overhead becomes measurable only when task execution drops below 100 microseconds, at which point the problem is better suited for GPU compute or SIMD anyway.
Production deployments add monitoring by reserving extra shared memory for counters: total tasks claimed, total completed, peak queue depth. Workers increment these atomically to expose real-time telemetry without serialization overhead.
Pattern 3: Multi-Worker State Synchronization with Atomics.wait/notify
Complex worker pipelines often need barrier synchronization—all workers must reach a checkpoint before any can proceed. The Atomics.wait/notify primitives enable efficient barriers without polling. A coordinator worker counts arrivals with atomic increments and wakes the group when the count reaches the target.
class WorkerBarrier {
private state: Int32Array;
private workerCount: number;
private INDEX_ARRIVED = 0;
private INDEX_GENERATION = 1;
constructor(sharedBuffer: SharedArrayBuffer, workerCount: number) {
this.state = new Int32Array(sharedBuffer, 0, 2);
this.workerCount = workerCount;
Atomics.store(this.state, this.INDEX_ARRIVED, 0);
Atomics.store(this.state, this.INDEX_GENERATION, 0);
}
async wait(): Promise<void> {
const generation = Atomics.load(this.state, this.INDEX_GENERATION);
const arrived = Atomics.add(this.state, this.INDEX_ARRIVED, 1) + 1;
if (arrived === this.workerCount) {
// Last worker to arrive: reset and wake others
Atomics.store(this.state, this.INDEX_ARRIVED, 0);
Atomics.add(this.state, this.INDEX_GENERATION, 1);
Atomics.notify(this.state, this.INDEX_GENERATION, this.workerCount - 1);
} else {
// Wait for generation to increment
while (Atomics.load(this.state, this.INDEX_GENERATION) === generation) {
const result = Atomics.wait(this.state, this.INDEX_GENERATION, generation, 1000);
if (result === 'timed-out') {
console.warn('Barrier timeout: not all workers arrived');
break;
}
}
}
}
}
// Multi-phase computation with barriers
async function multiPhaseWorker(
workerId: number,
barrier: WorkerBarrier,
sharedData: Float32Array
) {
// Phase 1: Compute local results
const localResult = computePhase1(workerId, sharedData);
Atomics.store(sharedData, workerId, localResult);
// Wait for all workers to finish phase 1
await barrier.wait();
// Phase 2: Aggregate results from all workers
const aggregate = Array.from({ length: sharedData.length }, (_, i) =>
Atomics.load(sharedData, i)
).reduce((sum, val) => sum + val, 0);
// Use aggregate for phase 2 computation
const phase2Result = computePhase2(workerId, aggregate);
Atomics.store(sharedData, workerId, phase2Result);
// Wait for all workers to finish phase 2
await barrier.wait();
// Phase 3: Final local processing
const finalResult = computePhase3(workerId, sharedData);
return finalResult;
}The generation counter prevents ABA issues where a slow worker from the previous barrier round wakes up late and confuses itself with the current round. Each barrier cycle increments the generation, so workers check the generation value before waiting and only proceed when it changes.
This matters for pipelines like distributed ray tracing where each frame requires multiple synchronized passes. Workers trace rays in parallel, accumulate samples to shared buffers, and wait at a barrier before the next pass. Without barriers, workers would read partially-updated data from other workers and produce incorrect results.
The timeout mechanism is essential for production resilience. If one worker crashes mid-computation, the timeout ensures other workers don't deadlock waiting forever. Production systems detect timeouts and abort the entire computation rather than proceeding with incomplete data.
Performance Comparison: SharedArrayBuffer vs postMessage vs Transferables
Message passing through postMessage imposes serialization cost proportional to payload size. Structured cloning copies every field recursively, which takes 50-100 nanoseconds per field. A 1MB typed array with 262,144 float values takes 15-20ms to clone. Transferables eliminate the copy by moving ownership, but the sender loses access permanently.
SharedArrayBuffer eliminates both problems by keeping data in place and granting concurrent access. The write operation is a direct memory store—no serialization, no ownership transfer. For payloads above 100KB, shared memory outperforms message passing by 10-100x depending on payload structure.
flowchart LR
subgraph Small["Small Payload (<100KB)"]
A1("postMessage with clone") --> B1("5-10ms overhead")
A2("Transferable") --> B2("1-2ms overhead")
A3("SharedArrayBuffer") --> B3("0.01ms overhead")
end
subgraph Large["Large Payload (>1MB)"]
C1("postMessage with clone") --> D1("20-50ms overhead")
C2("Transferable") --> D2("1-2ms overhead")
C3("SharedArrayBuffer") --> D3("0.01ms overhead")
end
style B1 stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style D1 stroke:#ef4444,fill:#450a0a,color:#fca5a5
style B3 stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style D3 stroke:#34d399,fill:#0b3b2e,color:#d1fae5
The implication here is that small payloads (flags, counters, small strings) still favor postMessage for simplicity. The overhead is negligible—under 1ms—and the ergonomics are better. Shared memory shines when payloads grow large or when latency budgets are tight.
Transferables occupy a middle ground. They match shared memory's speed for one-shot transfers but break down when data needs to ping-pong between workers. A video encoder that transfers frames to a worker, gets them back after encoding, and transfers again pays the setup cost three times. Shared memory pays it once.
Benchmark data from a 2026 production video pipeline:
| Operation | Payload Size | postMessage | Transferable | SharedArrayBuffer |
|---|---|---|---|---|
| Send frame | 8.3MB (4K) | 22ms | 1.2ms | 0.008ms |
| Round-trip | 8.3MB | 44ms | 2.4ms | 0.016ms |
| 60fps budget | — | ❌ Misses | ✓ Meets | ✓ Meets |
The failure mode here is choosing the wrong primitive for the workload. A chat application sending 500-byte messages every second wastes effort on shared memory. A DAW processing 96kHz audio streams fails with message passing.
Production Considerations: When to Use Shared Memory vs Message Passing
The decision tree starts with latency requirements. If the system can tolerate 10ms+ delays, message passing is simpler and sufficient. Real-time systems with sub-5ms budgets demand shared memory. This distinction is critical because the complexity cost of shared memory—synchronization bugs, race conditions, memory layout decisions—only pays off when message passing fundamentally cannot meet the performance target.
Data ownership patterns matter. If workers operate on disjoint data sets (embarrassingly parallel problems), transferables win on simplicity. If multiple workers read and write the same data concurrently, shared memory is the only option. The implication here is that systems combining both patterns—large immutable payloads transferred, small mutable state shared—get the best of both worlds.
flowchart LR
A("Evaluate workload") --> B{"Latency budget?"}
B -- ">10ms" --> C("Use postMessage")
B -- "<5ms" --> D{"Payload size?"}
D -- "<100KB" --> E("Consider postMessage with small buffers")
D -- ">100KB" --> F("Use SharedArrayBuffer")
F --> G("Implement atomic synchronization")
G --> H("System meets real-time constraints")
C --> I("System meets general throughput needs")
style H stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style I stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style C stroke:#7c9cf0,fill:#142544,color:#eaf2ff
style F stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
Security requirements impose hard constraints. Cross-origin isolation blocks third-party widgets, embedded iframes, and certain analytics scripts. Teams must audit dependencies and ensure all resources either live on the same origin or opt in with CORP headers. For applications heavily reliant on third-party integrations, this requirement might block SharedArrayBuffer adoption entirely.
Debugging shared memory systems demands new tooling. Chrome DevTools shows shared buffer contents, but race conditions remain invisible until they cause corruption. Production teams instrument critical sections with atomic counters that track entry/exit, exposing concurrency bugs through anomalies in the telemetry. A counter that decrements more than it increments signals a missed store or torn read.
The maintenance burden grows with complexity. A ring buffer requires 200 lines of careful atomic coordination. A message-passing equivalent is 20 lines of straightforward postMessage calls. The 10x complexity multiplier only makes sense when the performance delta is comparably large—which it is for real-time audio, video encoding, high-frequency trading, and scientific simulation, but not for most CRUD applications.
When to choose shared memory:
- Real-time audio/video processing with <5ms latency budgets
- High-throughput data pipelines processing >10MB/sec
- Scientific computing requiring fine-grained parallel coordination
- Live collaboration systems where dozens of workers synchronize state
When to stick with message passing:
- CRUD applications with moderate throughput
- Systems requiring third-party widgets or cross-origin resources
- Prototypes and MVPs where simplicity outweighs performance
- Any workload meeting its latency budget with
postMessage
Frequently Asked Questions
Can SharedArrayBuffer be used in the main thread?
Yes, but Atomics.wait cannot block the main thread because that would freeze the UI. The main thread can create shared buffers, perform atomic operations like store and compareExchange, and call Atomics.notify to wake workers. Only workers can call Atomics.wait to suspend execution.
What happens if cross-origin isolation headers are missing?
The browser disables SharedArrayBuffer entirely, throwing an error if code tries to construct one. The application must fall back to message passing or transferables. As of 2026, all major browsers enforce this requirement consistently with no opt-out.
How do you prevent data races in shared memory?
Use atomic operations for all concurrent access to shared state. Never mix atomic and non-atomic access to the same memory location. Structure data so each worker owns distinct regions, and use atomic indices or flags to coordinate ownership transfers. Thorough testing under high concurrency reveals most race conditions.
Does SharedArrayBuffer work in Safari and Firefox?
Yes, both browsers shipped stable support for SharedArrayBuffer with cross-origin isolation in 2021-2022. As of 2026, the API surface is consistent across Chrome, Firefox, Safari, and Edge. Older browsers from before 2020 lack support entirely.
What is the maximum size for a SharedArrayBuffer?
The specification allows up to 2^53 bytes (8 petabytes), but practical limits depend on available RAM and browser implementation. Most browsers cap individual buffers at 2GB to prevent abuse. Systems needing larger data sets should split across multiple buffers or use file-backed storage with incremental loading.
Closing
That covers the essential patterns for cross-worker shared state. Apply these in production and the difference will be immediate—audio pipelines that stuttered under message-passing latency stabilize, video encoders that dropped frames hit their deadlines, and data processing pipelines that serialized multi-megabyte structures every tick eliminate the bottleneck entirely. The complexity cost of SharedArrayBuffer and atomics is real, but for workloads with tight latency budgets or large payloads, the performance payoff justifies the investment. The web platform now ships this capability reliably across all major browsers. Teams that master it unlock performance headroom that message passing fundamentally cannot match.
For more JavaScript performance patterns, see 11 JavaScript Examples to Source Code That Reveal Design Patterns in Use, 10 JavaScript Practices You Should Know Before Tomorrow, and 10 JavaScript and Node.js Tips That Knock Away Multiple Concepts.