A Fixed-Capacity Ring with Head/Tail Mod — Zero Allocation After Init
Circular Buffer
A circular buffer wraps a fixed TypedArray into a ring using modular index arithmetic, enabling O(1) enqueue and dequeue with no heap allocation.
What you'll learn
- Implement a circular buffer backed by a TypedArray using mod arithmetic
- Explain head/tail index advancement and how to detect full vs. empty
- Identify real-world use cases where a ring buffer beats a dynamic queue
A circular buffer (also called a ring buffer) is a fixed-size array where the write pointer wraps back to index 0 when it reaches the end — as if the array were bent into a circle. Once allocated, no further heap allocation occurs, which makes it attractive for real-time and memory-constrained environments.
Core Idea
Two pointers, head and tail, track the next position to read and write.
Advancing either pointer uses modular arithmetic:
nextIndex = (currentIndex + 1) % capacity
The buffer is empty when head === tail. It is full when
(tail + 1) % capacity === head — we sacrifice one slot to distinguish the
two states without a separate counter.
Implementation
class CircularBuffer {
#buf;
#capacity;
#head = 0; // next read position
#tail = 0; // next write position
constructor(capacity) {
// Use Int32Array for integer data; swap for Float64Array, Uint8Array, etc.
this.#capacity = capacity + 1; // one extra slot for full/empty distinction
this.#buf = new Int32Array(this.#capacity);
}
enqueue(value) {
if (this.isFull()) throw new Error("Buffer is full");
this.#buf[this.#tail] = value;
this.#tail = (this.#tail + 1) % this.#capacity;
}
dequeue() {
if (this.isEmpty()) throw new Error("Buffer is empty");
const value = this.#buf[this.#head];
this.#head = (this.#head + 1) % this.#capacity;
return value;
}
peek() {
if (this.isEmpty()) throw new Error("Buffer is empty");
return this.#buf[this.#head];
}
isEmpty() {
return this.#head === this.#tail;
}
isFull() {
return (this.#tail + 1) % this.#capacity === this.#head;
}
get size() {
return (this.#tail - this.#head + this.#capacity) % this.#capacity;
}
}
const ring = new CircularBuffer(4);
ring.enqueue(10);
ring.enqueue(20);
ring.enqueue(30);
console.log(ring.dequeue()); // 10
ring.enqueue(40);
ring.enqueue(50);
console.log(ring.size); // 4 Visualising the Ring
After enqueueing 10, 20, 30 into a capacity-4 buffer and dequeueing 10:
Index: 0 1 2 3 (4 = ghost slot)
Value: [20, 30, _, _, _]
^head=0 ^tail=2
Both head and tail advance right, wrapping at the end, so old slots are
reused automatically.
Complexity
| Operation | Time | Space |
|---|---|---|
| enqueue | O(1) | O(1) |
| dequeue | O(1) | O(1) |
| peek | O(1) | O(1) |
| Allocation (once) | O(n) | O(n) |
When to Use a Circular Buffer
- Streaming logs — keep the last N log lines without unbounded growth.
- Audio / video pipelines — producer (codec) and consumer (speaker) run at different rates; the ring smooths out the difference.
- Hardware drivers — fixed memory footprint is a hard requirement.
- Rate-limiting token buckets — store timestamps of recent requests in a ring and count those within the window.
Avoid circular buffers when the required capacity is unknown at construction time; a dynamic linked-list queue is more appropriate there.
Up Next
Put stacks to work parsing and evaluating mathematical expressions with the Shunting Yard algorithm.
Expression Evaluation →