Building a Lock-Free Ring Buffer with Atomics

A ring buffer is the shared-memory answer to a stream of values crossing a thread boundary: fixed allocation, no locks, no serialisation, and a per-item cost measured in nanoseconds rather than microseconds.

This page narrows the SharedArrayBuffer & Atomics reference — part of the Web Workers Architecture & Communication overview — to one concrete artefact: a working single-producer/single-consumer (SPSC) queue you can paste into a worker pipeline today. The parent page covers the memory model and the isolation requirements in general; here we only care about the slot layout, the publish order, and the handful of ways this pattern goes quietly wrong.

COOP / COEP required

A SharedArrayBuffer only exists when the document is cross-origin isolated. The initial navigation response must carry both Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. Check console.log(crossOriginIsolated) — anything other than true and the constructor is either missing or hands you a buffer that cannot be shared. If it reports false, work through Debugging SharedArrayBuffer Cross-Origin Errors.

Buffer Layout: Which Slot Holds What

Everything the two threads know about each other lives in one flat Int32Array. Two header slots hold the indices; the rest is the data region.

Slot Byte range Sole writer Meaning
0 0–3 Consumer head — next slot to read
1 4–7 Producer tail — next slot to write
2 … capacity + 1 8 … Producer (read by consumer) Data region, one Int32 per slot

The single-writer column is the design. Because no index has two writers, neither index needs a compare-and-swap or a lock — a plain Atomics.store is a complete, correct publish. The buffer is full when advancing the tail by one would land on the head, and empty when the two are equal; that ambiguity is resolved by permanently sacrificing one slot, so a capacity of 256 holds 255 items.

One flat Int32Array: two owned index slots, then the ring A horizontal strip of cells. Slot 0 is the head index, written only by the consumer; slot 1 is the tail index, written only by the producer. Everything from slot 2 onward is the data region, shown here as twelve cells carrying ring indices 0 to 11. The head points at ring index 3 and the tail at ring index 9, so ring indices 3 to 8 hold the six readable items. The cell immediately before the head, ring index 2, is the permanently sacrificed slot that keeps the full state distinguishable from the empty state. A curved arrow runs from the right-hand end of the data region back to its start, showing that the next index after the last is index 0 again. One flat Int32Array: two owned index slots, then the ring control header head — consumer’s cursor tail — producer’s cursor slot 0 head slot 1 tail 2 3 4 5 6 7 8 9 10 11 12 13 ring index → 0 1 2 3 4 5 6 7 8 9 10 11 6 items readable — ring 3 … 8 wrap-around: (i + 1) % capacity filled — readable by the consumer free — writable by the producer sacrificed — never written Slot 0 has exactly one writer and slot 1 has exactly one writer — that single-writer rule is what removes the lock.
Ring index i lives at array slot 2 + i. The tail may advance until the next step would land on the head, which is why one slot stays permanently empty: it is the only thing separating full from empty when both are expressed as a comparison of two indices.

Minimal Reproducible Example

Four files: the shared module, a producer worker, a consumer worker, and the main thread that wires them together.

// ring-buffer.ts — imported by both workers; no DOM, no side effects
export const HEADER_SLOTS = 2; // slot 0 = head, slot 1 = tail
export const HEAD_SLOT = 0;
export const TAIL_SLOT = 1;

export function createRingBuffer(capacity: number): SharedArrayBuffer {
  const sab = new SharedArrayBuffer(
    (capacity + HEADER_SLOTS) * Int32Array.BYTES_PER_ELEMENT,
  );
  const view = new Int32Array(sab);
  Atomics.store(view, HEAD_SLOT, 0);
  Atomics.store(view, TAIL_SLOT, 0);
  return sab;
}

export const capacityOf = (view: Int32Array): number =>
  view.length - HEADER_SLOTS;

/** Producer only. Returns false when the buffer is full — nothing is written. */
export function enqueue(view: Int32Array, value: number): boolean {
  const capacity = capacityOf(view);
  const tail = Atomics.load(view, TAIL_SLOT); // we are the only writer
  const nextTail = (tail + 1) % capacity;

  if (nextTail === Atomics.load(view, HEAD_SLOT)) return false; // full

  view[HEADER_SLOTS + tail] = value;          // 1. payload, plain write
  Atomics.store(view, TAIL_SLOT, nextTail);   // 2. publish (release edge)
  Atomics.notify(view, TAIL_SLOT, 1);         // 3. wake a parked consumer
  return true;
}

/** Consumer only. Returns null when the buffer is empty. */
export function dequeue(view: Int32Array): number | null {
  const capacity = capacityOf(view);
  const head = Atomics.load(view, HEAD_SLOT);

  if (head === Atomics.load(view, TAIL_SLOT)) return null; // empty (acquire)

  const value = view[HEADER_SLOTS + head];                 // 1. payload
  Atomics.store(view, HEAD_SLOT, (head + 1) % capacity);   // 2. release slot
  Atomics.notify(view, HEAD_SLOT, 1);                      // 3. wake a full producer
  return value;
}
// producer-worker.ts
import { enqueue, HEAD_SLOT } from './ring-buffer.js';

self.onmessage = ({ data }: MessageEvent<{ type: 'INIT'; sab: SharedArrayBuffer }>) => {
  if (data.type !== 'INIT') return;
  const view = new Int32Array(data.sab);

  for (let i = 0; i < 1_000_000; i++) {
    while (!enqueue(view, i)) {
      // Full is a normal condition, not an error. Park on the head slot with the
      // value we just saw: if the consumer already drained one, wait() returns
      // 'not-equal' and we retry immediately.
      Atomics.wait(view, HEAD_SLOT, Atomics.load(view, HEAD_SLOT), 1000);
    }
  }
};
// consumer-worker.ts — blocking drain loop; legal because this is not the main thread
import { dequeue, HEAD_SLOT, TAIL_SLOT } from './ring-buffer.js';

self.onmessage = ({ data }: MessageEvent<{ type: 'INIT'; sab: SharedArrayBuffer }>) => {
  if (data.type !== 'INIT') return;
  const view = new Int32Array(data.sab);
  let consumed = 0;

  for (;;) {
    const value = dequeue(view);
    if (value !== null) {
      if (++consumed % 100_000 === 0) self.postMessage({ type: 'PROGRESS', consumed });
      continue;
    }
    // Empty. Park on the tail slot with the value we just observed:
    // if the producer advanced it in the meantime, wait() returns 'not-equal'.
    const tail = Atomics.load(view, TAIL_SLOT);
    if (Atomics.load(view, HEAD_SLOT) === tail) {
      Atomics.wait(view, TAIL_SLOT, tail, 1000);
    }
  }
};
// main.ts
import { createRingBuffer } from './ring-buffer.js';

const sab = createRingBuffer(1024); // 1023 usable slots, 4 KiB of data
const producer = new Worker(new URL('./producer-worker.ts', import.meta.url), { type: 'module' });
const consumer = new Worker(new URL('./consumer-worker.ts', import.meta.url), { type: 'module' });

consumer.postMessage({ type: 'INIT', sab }); // start the drain side first
producer.postMessage({ type: 'INIT', sab });

consumer.onmessage = ({ data }) => {
  if (data.type === 'PROGRESS') console.log('consumed', data.consumed);
};

Step-by-Step Walkthrough

createRingBuffer — zero the header before anyone can read it. The allocation is (capacity + 2) * 4 bytes; the factor of four is Int32Array.BYTES_PER_ELEMENT. A fresh SharedArrayBuffer is already zero-filled, so the two Atomics.store calls are strictly belt-and-braces — but they document ownership and they make the function safe to reuse on a recycled buffer. Note that the buffer is created before either worker starts and travels to both by postMessage; unlike a transferable object, it is not detached from the sender, which is exactly the point.

enqueue — the producer owns the tail. It loads its own tail (it is the only writer, so no other agent can have changed it), computes nextTail, and compares that against an atomic load of the head. Reading the head atomically is what makes fullness detection meaningful: it is the acquire side of whatever the consumer last released. If nextTail === head, the buffer is full and the function returns without touching anything — a failed enqueue must be a no-op, or a retry will duplicate data.

The order of the last two writes is the entire correctness argument:

  1. Payload firstview[HEADER_SLOTS + tail] = value
  2. Tail secondAtomics.store(view, TAIL_SLOT, nextTail)

The Atomics.store is sequentially consistent, and the consumer’s Atomics.load of the same slot synchronizes-with it. Because the payload write is sequenced before the store in the producer, and the store synchronizes-with the consumer’s load, the payload write happens-before every read the consumer performs afterwards. Swap the two lines and the consumer may observe an advanced tail pointing at a slot that still holds last lap’s value.

Why the payload write must be sequenced before the atomic publish Two stacked two-lane sequence panels, producer on the left and consumer on the right. In the upper, correct panel the producer first writes the payload into the data slot with a plain assignment, then calls Atomics.store on the tail slot; that store synchronizes-with the consumer's Atomics.load of the same slot, and only afterwards does the consumer read the slot, so it observes the value just written. In the lower, incorrect panel the two producer statements are swapped: the atomic store publishes the advanced tail first, the consumer loads that tail and reads the slot immediately, and the producer's payload write lands afterwards — so the consumer returns the value left in that slot on the previous lap. Two statements, two outcomes: the publish order is the correctness argument Correct — payload first, then the atomic publish Producer worker Consumer worker 1 · view[HEADER + tail] = value 2 · Atomics.store(TAIL, next) synchronizes-with 3 · Atomics.load(TAIL) 4 · read view[HEADER + head] The payload write is sequenced-before the store, so it happens-before every read the consumer makes after the load. Swapped — the publish overtakes the payload Producer worker Consumer worker 1 · Atomics.store(TAIL, next) tail advances first 2 · Atomics.load(TAIL) — sees it too late — the read already happened 4 · payload write lands here 3 · reads last lap’s value Nothing orders the payload write against a load that has already returned — the consumer keeps the stale value.
Both panels run the same two statements; only their order differs. In the lower panel the consumer’s Atomics.load still synchronizes-with the store — but the payload write is no longer sequenced before it, so the guarantee covers nothing that matters.

dequeue — the consumer owns the head, mirrored exactly. It loads its own head, compares against an atomic load of the tail for emptiness, reads the payload, and only then stores the advanced head. Advancing the head first would hand the slot back to the producer while the consumer is still reading it — a torn read on a value that a Int32Array element would otherwise deliver intact.

Atomics.notify on the tail slot — a wake-up that cannot be lost. The classic bug in signal-slot designs is the missed notify: the producer signals in the window after the consumer decides to sleep but before it actually sleeps, and the consumer parks forever. Waiting directly on the tail slot eliminates that window, because Atomics.wait(view, TAIL_SLOT, tail, ...) re-checks the slot atomically as part of parking. If the producer advanced the tail even a nanosecond earlier, wait returns 'not-equal' immediately and the loop goes straight back to draining. The producer uses the mirror image of the same trick on the head slot when the buffer is full, which is why dequeue notifies. The full mechanics of futex-style parking — including Atomics.waitAsync for the main thread, which is forbidden from blocking — are covered in Coordinating Workers with Atomics.wait and notify.

Full and empty without a counter. A separate count slot would need an Atomics.add on every enqueue and every dequeue, putting both threads on the same cache line for every single item — the exact contention this design avoids. Sacrificing one slot buys unambiguous states for free: head === tail is empty, (tail + 1) % capacity === head is full.

Batching: One Publish Per Block, Not Per Item

The single-item API above is a teaching tool. In production the interesting version moves blocks, because a TypedArray.prototype.set of 1024 elements costs barely more than one element, while 1024 atomic index publishes cost 1024 cache-line round trips. Batching also forces you to handle the wrap point explicitly: a block that runs off the end of the data region becomes two copies.

// ring-buffer.ts (continued) — block API over the identical layout,
// so the constants and capacityOf above are already in scope.

/** Producer only. Returns how many elements were actually written (0…src.length). */
export function push(view: Int32Array, src: Int32Array): number {
  const capacity = capacityOf(view);
  const tail = Atomics.load(view, TAIL_SLOT);
  const head = Atomics.load(view, HEAD_SLOT);
  const free = (head - tail - 1 + capacity) % capacity; // one slot always reserved
  const n = Math.min(src.length, free);
  if (n === 0) return 0;

  const firstRun = Math.min(n, capacity - tail);        // up to the end of the region
  view.set(src.subarray(0, firstRun), HEADER_SLOTS + tail);
  if (n > firstRun) view.set(src.subarray(firstRun, n), HEADER_SLOTS); // wrapped tail

  Atomics.store(view, TAIL_SLOT, (tail + n) % capacity); // single publish
  Atomics.notify(view, TAIL_SLOT, 1);
  return n;
}

/** Consumer only. Fills dst and returns how many elements were read. */
export function pop(view: Int32Array, dst: Int32Array): number {
  const capacity = capacityOf(view);
  const head = Atomics.load(view, HEAD_SLOT);
  const tail = Atomics.load(view, TAIL_SLOT);
  const available = (tail - head + capacity) % capacity;
  const n = Math.min(dst.length, available);
  if (n === 0) return 0;

  const firstRun = Math.min(n, capacity - head);
  dst.set(view.subarray(HEADER_SLOTS + head, HEADER_SLOTS + head + firstRun), 0);
  if (n > firstRun) {
    dst.set(view.subarray(HEADER_SLOTS, HEADER_SLOTS + (n - firstRun)), firstRun);
  }

  Atomics.store(view, HEAD_SLOT, (head + n) % capacity);
  return n;
}

Both functions read the other thread’s index exactly once and act on that snapshot. That snapshot can only be stale in the safe direction — the consumer may drain further while push is copying, which merely means push wrote less than it could have. It can never become stale in a direction that lets the two threads touch the same slot.

A block that runs off the end becomes two copies and one publish A source array of ten elements sits above a sixteen-slot data region whose tail is at index 12 and head at index 8. The first four source elements form run one and are copied into slots 12 to 15, the last stretch of the region; the remaining six form run two and are copied into slots 0 to 5 at the start of the region. Slots 8 to 11 already hold unread items. After both copies complete, a single Atomics.store moves the tail from 12 to 6, publishing the whole block at once. One block across the wrap point: two copies, one publish src — 10 elements handed to push() 0 1 2 3 4 5 6 7 8 9 run 1 — tail … end run 2 — back to slot 0 data region — 16 slots, indices wrap at the end 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 tail = 6 after head = 8 tail = 12 before Two view.set() calls, one Atomics.store — the consumer sees none of the block or all of it, never half.
Slots 8–11 are the items still waiting to be read; the copy lands in the free span behind the tail and stops one slot short of the head. firstRun = Math.min(n, capacity - tail) is the only line that knows about the wrap — everything after it is a single index publish.

Gotchas & Edge Cases

Atomics on the payload is a throughput trap, not a safety upgrade

The instinct to wrap every data-slot access in Atomics.store and Atomics.load is understandable and wrong. It buys nothing — the index publish already establishes happens-before for everything written before it — and it costs you bulk copying entirely, because there is no atomic equivalent of TypedArray.prototype.set. The rule to memorise: atomic on the indices, plain on the payload, and never read a slot you have not claimed by comparing indices. Access outside that discipline is a data race, and the specification says a racy read may return any value the location has ever held.

head and tail sharing a cache line costs you half your throughput

Slots 0 and 1 are eight bytes apart, so both indices live on the same 64-byte cache line. Every producer publish invalidates that line on the consumer’s core and vice versa — false sharing, on the one line both threads touch constantly. Pad the header so each index owns its own line:

export const HEAD_SLOT = 0;    // cache line 0
export const TAIL_SLOT = 16;   // cache line 1 — 16 × 4 bytes = 64 bytes apart
export const HEADER_SLOTS = 32; // data starts on cache line 2

Nothing else in the code changes: capacityOf still derives capacity from view.length - HEADER_SLOTS. On a sustained single-item benchmark this padding is routinely worth 1.5–2× on multi-core x86, and it is free — 120 wasted bytes.

False sharing: two indices on one cache line, and the padded fix Two panels. On the left, the unpadded header puts the head index in slot 0 and the tail index in slot 1, eight bytes apart and therefore on the same sixty-four byte cache line; both the consumer core and the producer core point at that single line, and a double-headed arrow between the cores shows the line bouncing between them on every store. On the right, the padded header puts the head on cache line 0 and the tail on cache line 1, sixty-four bytes apart; each core writes only its own line and there is no coherence traffic between them, which is worth roughly one and a half to two times the throughput for one hundred and twenty wasted bytes. Two indices, one cache line — and the padding that separates them Unpadded — both indices, one line Padded — one line each H T one 64-byte line — H and T 8 bytes apart Consumer core stores head Producer core stores tail line bounces on every publish every store invalidates the other core H line 0 — head + padding T line 1 — tail + padding Consumer core stores head only Producer core stores tail only no coherence traffic between them 1.5–2× throughput for 120 wasted bytes
Both cores still read the other index — reads are cheap and can be served from a shared copy of the line. It is the stores that take the line exclusively, and in the left-hand layout every store takes the one line the other core is about to read.

Power-of-two capacity, and the counters that quietly overflow

With a power-of-two capacity you can replace (i + 1) % capacity with (i + 1) & (capacity - 1), which is a real win in the batched hot path. The tempting next step — free-running counters that never wrap, so tail - head is the fill level and no slot is sacrificed — has a trap in JavaScript: Int32Array elements wrap to negative at 2³¹, and past that point tail - head is wrong. At a million items per second that is about 36 minutes of uptime. Either keep the modular indices shown here, or move the counters to a BigInt64Array (which Atomics supports, and which Atomics.wait also accepts).

Capacity must never be re-derived from a resized buffer

If you allocate with new SharedArrayBuffer(bytes, { maxByteLength }) and later call grow(), length-tracking views expand and capacityOf starts returning a larger number — on one thread before the other. The modular arithmetic on both sides is then computed against different capacities, and the queue corrupts silently. Treat a ring buffer’s capacity as immutable: allocate for the worst case, or build a second buffer and hand it over as an explicit generation change.

The main thread cannot park on it

Atomics.wait throws a TypeError on the main thread, so the drain loop above is worker-only. If the main thread is the consumer — reading a worker’s output for rendering — poll the buffer once per requestAnimationFrame and drain everything available in that frame, or use Atomics.waitAsync and resume on the returned promise. Deciding whether the stream justifies shared memory at all is the subject of postMessage vs SharedArrayBuffer: When to Choose Each.

Performance Note

Rule of thumb

Single-item enqueue/dequeue through this buffer costs roughly 20–40 ns per item with both threads hot on a modern x86 core — call it 25–50 million items per second, and the cost is dominated by the index cache line bouncing between cores, not by the copy. The block API removes that per-item publish and runs at memory-copy speed, several GB/s.

Put next to postMessage, the comparison is not close: a small message pays structured-clone serialisation, a task dispatch, and a receiver wake-up, for a latency floor in the tens of microseconds — three orders of magnitude per item. But that comparison only holds for a continuous stream where both ends are already running. For a one-shot handoff of a large payload, a transferred ArrayBuffer is simpler, has no capacity to size and no wrap arithmetic to get wrong, and moves the bytes in constant time; measure your own workload with the technique in Measuring Structured Clone Cost with performance.now() before assuming a ring buffer is warranted.

Per-item cost on a logarithmic scale: block copy, single-item publish, postMessage A logarithmic axis running from one nanosecond to one hundred microseconds carries three range bars. The block push and pop API costs about one to two nanoseconds per item, running at memory-copy speed. A single-item enqueue and dequeue pair costs twenty to forty nanoseconds per item, dominated by one cache-line round trip between the two cores. A postMessage carrying a structured clone costs ten to fifty microseconds per message — roughly three orders of magnitude more than the shared-memory publish. Cost per item, logarithmic scale — both threads already hot Block API — push / pop, 1024 at a time ≈ 1–2 ns per item — memory-copy speed, one publish per block Single-item enqueue / dequeue 20–40 ns per item — one cache-line round trip dominates postMessage with structured clone 10–50 µs per message — serialise, dispatch, wake 1 ns 10 ns 100 ns 1 µs 10 µs 100 µs Each gridline is ten times the last: the gap between the middle bar and the bottom one is three orders of magnitude.
The ladder only reads this way for a continuous stream with both ends already running. A single large handoff pays none of the per-item cost, which is why a transferred ArrayBuffer still wins that case outright.

Size the capacity from burst tolerance rather than throughput. If the producer emits 48 000 samples per second and the consumer may be descheduled for a 16 ms frame, the buffer must hold at least 768 items to survive one stall without dropping — round up to 2048 and stop worrying about it. The memory is trivial; the dropped-sample bug you avoid is not.

Frequently Asked Questions

Why does this ring buffer only work with one producer and one consumer?
The whole design rests on one invariant: exactly one agent ever writes the head index and exactly one agent ever writes the tail index. That is what removes the need for a lock — neither writer has to compete for its own index, so a plain Atomics.store is enough to publish it. Add a second producer and both will read the same tail, both will conclude the same slot is free, and both will write it. Turning the buffer multi-producer means replacing the tail store with a Atomics.compareExchange retry loop, and then separating claiming a slot from publishing it — usually with a per-slot sequence number — because a claimed-but-not-yet-written slot must not be visible to the consumer. That is a materially different data structure; do not bolt it onto this one.
Do the data slots have to be written with Atomics.store, or are plain typed-array writes safe?
Plain writes are safe, provided every data write is sequenced before the Atomics.store that publishes the new tail, and the consumer only reads slots strictly below the tail value it loaded atomically. The ECMAScript memory model gives you that guarantee: the consumer’s Atomics.load of the tail synchronizes-with the producer’s Atomics.store of the tail, and everything sequenced before that store — including unordered writes to the data region — happens-before everything the consumer does after the load. This matters for throughput: Atomics.store per element defeats bulk copying, whereas plain writes let you move a whole block with TypedArray.prototype.set at memcpy speed. The rule is that the indices must be atomic, not the payload.

See also