Circular Buffer

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.

5 min read Level 3/5 #dsa#circular-buffer#ring-buffer
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

OperationTimeSpace
enqueueO(1)O(1)
dequeueO(1)O(1)
peekO(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 →