JavaScript Atomics and SharedArrayBuffer in 2026: Practical Patterns for Cross-Worker State
DEV Community

JavaScript Atomics and SharedArrayBuffer in 2026: Practical Patterns for Cross-Worker State

JavaScript Atomics and SharedArrayBuffer in 2026: Practical Patterns for Cross-Worker State

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.

Key Takeaways

  • 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.
  • Cross-origin isolation (COOP and COEP headers) is mandatory for SharedArrayBuffer since 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 postMessage or Transferable for 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.

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.

Comments

No comments yet. Start the discussion.