JavaScript Atomics and SharedArrayBuffer in 2026: Practical Patterns for Cross-Worker State A developer explores practical patterns for using JavaScript Atomics and SharedArrayBuffer in 2026 to enable efficient cross-worker state sharing. The post demonstrates how shared memory eliminates serialization overhead, reducing frame copy times from 15-20ms to under 2ms for real-time workloads like video processing. It covers atomic operations for lock-free coordination and the security requirements of cross-origin isolation. This article was written with the assistance of AI, under human supervision and review. 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. 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. 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. SharedArrayBuffer eliminates serialization overhead by giving workers direct access to the same memory, turning 15ms postMessage copies into microsecond atomic operations for real-time workloads. Atomics.compareExchange and Atomics.wait/notify provide lock-free coordination primitives that replace mutex-heavy approaches, enabling ring buffers and work queues without blocking. SharedArrayBuffer since 2020, but the security model is stable and shipping in all modern browsers as of 2026. postMessage or Transferable for simplicity. 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. Setting up shared memory in practice looks like this: js // 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. 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 . 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. 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. js // 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. 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