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.
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.
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:
- Payload first —
view[HEADER_SLOTS + tail] = value - Tail second —
Atomics.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.
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.
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.
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
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.
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.