Articles

What Is a Ring Buffer?

A ring buffer is a fixed-size array that wraps its read and write pointers around, giving O(1) enqueue and dequeue without ever resizing.

The Lycoris Team The Lycoris Team · · 5 min read
Diagrams and equations written on a chalkboard

A ring buffer — also called a circular buffer — is a fixed-size array that treats its last position as connected back to its first, so a write or read pointer that reaches the end wraps around to index zero instead of running off the array. That wraparound is the whole trick: it turns a plain array into a queue that supports constant-time insertion and removal at both ends without ever shifting elements or reallocating memory.

The problem with a plain array as a queue

A linked list makes a natural queue — push onto one end, pop from the other, both in constant time. An array is trickier: popping from the front of a normal array means shifting every remaining element left by one position to close the gap, an O(n) operation. Growing the array to hold new elements means allocating a bigger block and copying everything over. Neither problem is fatal, but both add cost a ring buffer avoids entirely for a specific, common use case: a queue with a known maximum size.

How the wraparound works

A ring buffer keeps two pointers (or indices) into a fixed-size array: a head marking the next slot to read from, and a tail marking the next slot to write to. Both pointers move forward as data flows through the buffer, and both wrap back to zero using the modulo operation once they reach the array’s length:

class RingBuffer {
  constructor(capacity) {
    this.buffer = new Array(capacity);
    this.capacity = capacity;
    this.head = 0;
    this.tail = 0;
    this.size = 0;
  }

  enqueue(item) {
    if (this.size === this.capacity) throw new Error("buffer full");
    this.buffer[this.tail] = item;
    this.tail = (this.tail + 1) % this.capacity;
    this.size++;
  }

  dequeue() {
    if (this.size === 0) throw new Error("buffer empty");
    const item = this.buffer[this.head];
    this.head = (this.head + 1) % this.capacity;
    this.size--;
    return item;
  }
}

Every enqueue and dequeue is O(1) — no shifting, no resizing, just an index update and a modulo. The size counter (or, in some implementations, one deliberately unused slot) is what distinguishes a full buffer from an empty one, since head === tail alone is ambiguous between the two states.

Why fixed capacity is the point, not a limitation

A ring buffer never grows past its initial capacity — enqueueing into a full buffer either raises an error, silently drops the oldest data by advancing head along with tail, or blocks until room is available, depending on the design. This looks like a constraint compared to a dynamically growing queue, but for the workloads ring buffers actually target, the fixed size is exactly what’s wanted: a known, bounded chunk of memory allocated once, with cost that never spikes from an unexpected resize, and behavior that degrades predictably (drop the oldest, or reject the newest) rather than growing without limit when a producer outpaces a consumer.

Where ring buffers actually show up

  • Audio and video streaming buffers, where a fixed-size window of recently produced samples needs to be available to a consumer running slightly behind the producer, and old samples naturally become irrelevant once newer ones exist.
  • Producer-consumer queues between threads, especially lock-free implementations, since a ring buffer’s fixed layout and simple index arithmetic make it easier to reason about correctness without a full mutex on every operation than a dynamically resizing structure would be.
  • Log buffers and telemetry, where the goal is “keep the most recent N events” and older entries are meant to be overwritten rather than retained indefinitely — a natural fit for a buffer that drops the oldest entry once full.
  • Network packet buffers inside kernels and network interface drivers, where fixed-size, pre-allocated memory avoids the unpredictable latency a dynamic allocation could introduce on a hot path.

Ring buffer vs other queue implementations

Ring bufferLinked-list queueDynamic array queue
Enqueue/dequeueO(1)O(1)O(1) amortized, O(n) on resize
Memory layoutFixed, contiguous, pre-allocatedScattered nodes, allocated per elementContiguous, resized as needed
Max sizeBounded, fixed at creationUnboundedUnbounded (grows)
Behavior when fullReject, overwrite, or blockN/A (always grows)Reallocates and grows
Cache friendlinessHigh (contiguous array)Low (pointer chasing)High (contiguous array)

A ring buffer’s contiguous layout is also friendlier to CPU caches than a linked list’s scattered nodes — sequential array access tends to pull useful data into cache lines together, whereas following pointers between heap-allocated nodes tends to miss more often. This is part of why ring buffers show up in latency-sensitive code even when a dynamically growing queue would be simpler to reason about.

Relationship to other bounded structures

A ring buffer solves a narrower problem than a general-purpose cache eviction structure like an LRU cache — an LRU cache needs to look up arbitrary keys and reorder based on access recency, which typically pairs a hash table with a linked list, while a ring buffer only ever reads from one end and writes to the other in strict order. If the access pattern is genuinely FIFO with a bounded size, a ring buffer is both simpler and faster than reaching for a full eviction-aware cache structure — the classic case of the big-O cost of an operation not telling the whole story, since a ring buffer and an LRU cache can share the same O(1) complexity for their core operations while differing enormously in constant-factor overhead and implementation complexity.

The takeaway

A ring buffer is a fixed-size array whose read and write pointers wrap around using modulo arithmetic, giving constant-time insertion and removal without shifting elements or reallocating memory. The fixed capacity that looks like a constraint is the actual design goal — predictable memory use and predictable behavior when full — which is exactly why ring buffers show up wherever a bounded, contiguous, cache-friendly queue matters more than an unbounded one: audio pipelines, lock-free producer-consumer queues, and anywhere “keep only the most recent N” is the actual requirement.

The Lycoris Team The Lycoris Team · · 4 min read

The KMP Algorithm: Fast String Matching Explained

The Knuth-Morris-Pratt algorithm finds a pattern inside a text in linear time by never re-examining characters it has already matched.

#Computer Science #Algorithms #Data Structures
The Lycoris Team The Lycoris Team · · 5 min read

Fenwick Trees (Binary Indexed Trees), Explained

A Fenwick tree, or binary indexed tree, answers prefix-sum queries and point updates in O(log n) with far less memory than a segment tree.

#Computer Science #Algorithms #Data Structures
The Lycoris Team The Lycoris Team · · 4 min read

Bit Manipulation Basics Every Developer Should Know

Bit manipulation uses operators like AND, OR, XOR, and shifts to work directly on binary representations — the basics behind flags, masks, and fast math.

#Computer Science #Algorithms #Data Structures