Ring buffer
A fixed-size queue stored in an array whose end wraps around to the start, so no item ever has to move.
O(1) per operationSpace O(capacity)4 practice problemsStep-through pictureThe idea
A ring buffer, also called a circular buffer, is a queue that lives in an array of fixed size. Instead of moving items when the front one leaves, it moves an index: head points at the oldest item, and new items go into the slot after the newest one.
When an index runs off the end of the array, it wraps around to slot 0, using % capacity. The array behaves like a circle with no first or last slot. As long as the buffer isn't full, there is always a free slot after the newest item.
Because the memory is set aside once and nothing ever moves, every push and remove is O(1) with no garbage to collect. That is why ring buffers are used for logs, audio, and network data that flow continuously.
An everyday example
A sushi conveyor belt
A sushi bar has a round conveyor belt with a fixed number of plate spots. The chef puts new plates in the next free spot, and customers take the oldest plate as it reaches them. The belt never gets longer and nothing is shuffled along it; it just keeps going round. When every spot holds a plate, the chef has to wait.
| In the example | In the code |
|---|---|
| The plate spots on the belt | this.items, a fixed-size array |
| The oldest plate | this.items[this.head] |
| The next free spot | (this.head + this.size) % this.capacity |
| Going past the last spot back to the first | % this.capacity |
| Every spot is full | this.size === this.capacity |
From the slow way to ring buffer
- Start with the obvious queue: an array with
pushandshift.shiftmoves every remaining item, so each dequeue isO(n). The usual fix keeps aheadindex and moves it instead, which makes dequeueO(1), but the served slots beforeheadare never reused, so the array grows forever on a stream that never stops. - Those served slots are free space. If the queue never holds more than
capacityitems at once, an array of exactlycapacityslots is enough, as long as new items can go into slots that earlier items left. When the back of the queue reaches the last slot, the next item should go into slot0again. - Wrapping is one operation:
x % capacityis the remainder after dividing bycapacity, so it always lands between0andcapacity - 1. Keephead, the slot of the oldest item, andsize, how many items are stored. The free slot after the newest item is(head + size) % capacity, and after a removalheadmoves to(head + 1) % capacity. - Tracking
sizealso settles the one hard question: is the buffer empty or full? Ifsize === 0there's nothing to remove, and ifsize === capacitythere's no room. The array is created once and nothing in it ever moves. That array with wrapping indexes is a ring buffer.
How it works
- Create
itemswithcapacityempty slots, and sethead = 0andsize = 0. - To push, return
falseifsize === capacity. Otherwise compute the free slottail = (head + size) % capacity. - Store the value at
items[tail]and add one tosize. - To remove the oldest item, return
nullifsizeis0. Otherwise readitems[head]and clear the slot. - Move
headforward with wrap-around:head = (head + 1) % capacity, and subtract one fromsize.
Watch it run
This picture runs the code below on a real input. Press Step to move one step at a time, or Play to watch. The highlighted line is the one that just ran. Change the input to see other cases.
See every step at onceThe same run, laid out side by side
The code
Point at a note to light up the lines it explains.
class RingBuffer {constructor(capacity) {this.items = new Array(capacity).fill(null);this.capacity = capacity;this.head = 0;this.size = 0;}push(value) {if (this.size === this.capacity) return false;const tail = (this.head + this.size) % this.capacity;this.items[tail] = value;this.size++;return true;}shift() {if (this.size === 0) return null;const value = this.items[this.head];this.items[this.head] = null;this.head = (this.head + 1) % this.capacity;this.size--;return value;}}
How fast it is
| Time | O(1) per operation |
|---|---|
| Extra memory | O(capacity) |
Each operation does a fixed amount of arithmetic and one array access. The array never grows, so memory depends only on capacity.
- Best case
O(1)A
pushinto a buffer with room, or ashiftfrom a buffer with items: a little arithmetic and one array write. - Average case
O(1)Any mix of calls, including ones where
heador the free slot wraps from the last slot to slot0. - Worst case
O(1)Even a push into a full buffer is one comparison that returns
false. No call ever loops or moves items.
Common mistakes
- Keeping separate
headandtailindexes without asize: when they are equal you can't tell an empty buffer from a full one. Tracksize, or always leave one slot empty. - Forgetting
% capacityin one place, so an index runs past the end of the array. - Using
array.shift()orspliceon the buffer, which moves items and loses the whole point. - Reading the items in array order from
0instead of fromhead, which mixes the newest and oldest items after a wrap.
When to use it
Look for these clues in a problem:
- The task gives a fixed capacity, such as "keep the last 100 log lines" or "a buffer of size
k". - Data flows continuously and only the newest
kitems matter. - You need a queue with
O(1)operations and no memory growth. - The words circular, wrap around, or moving average over the last
kvalues appear.
Check yourself
Answer each one in your head first, then open it.
Why track size instead of only head and tail indexes?
When the buffer is empty and when it is full, head and tail point to the same slot. size tells the two apart without wasting a slot.
With capacity = 5, head = 3, and size = 4, which slot does the next push write?
Slot 2, since (3 + 4) % 5 = 2. The items sit in slots 3, 4, 0, and 1.
Why is reading the array from index 0 the wrong way to list the items?
After a wrap, the oldest item may be in the middle of the array and newer items at slot 0. The order starts at head, as the trace showed when 44 sat in slot 0 but came out last.
How much memory does the buffer use after a million pushes and shifts?
The same as at the start: capacity slots. The array is never resized, so only the values in it change.
Practice problems
Stuck on one? Tuto is in the playground too, and gives you hints, never the answer.
0 of 4 solved. Solve one to earn the bronze medal, half for silver, and all of them for gold.
Start practicingGo further
You don't need these to start practicing. Open one when you want more.
Why it works
The reasoning that makes it always right
The class keeps one rule after every call: size is between 0 and capacity, the stored items, oldest first, sit in slots head, head + 1, and so on for size slots, each taken % capacity, and every other slot is null. At the start size is 0 and every slot is null, so the rule holds.
push runs only when size < capacity, so at least one slot is free, and the first one after the newest item is (head + size) % capacity. Writing there and adding one to size extends the run by one at its end. shift runs only when size > 0; it reads and clears slot head, moves head one slot forward with wrap-around, and subtracts one from size, which drops the run's first item and keeps the rest in order.
So the oldest item is always at head and the next free slot is always right after the newest, wherever the run wraps. Every index passes through % capacity, so no index ever leaves the array, and each method does a fixed amount of work: O(1).
Going deeper
3 more ideas
Why the modulo makes a circle
x % capacity is the remainder after dividing by capacity, so it is always between 0 and capacity - 1. Index capacity becomes 0, capacity + 1 becomes 1, and so on. The items sit in slots head, head + 1, ... up to size slots, each taken % capacity, so they may wrap past the end and continue at the start.
Cost
Push and remove do a few arithmetic steps and one array write, so both are O(1). The array is created once with capacity slots, so memory is O(capacity) no matter how many items pass through.
Overwriting instead of refusing
Logs and metrics usually prefer to keep the newest data. In that version, a push to a full buffer writes into slot head (the oldest item) and moves head forward by one, so the oldest item is dropped and size stays at capacity.
Work it by hand
Every step in a table, with why
Use a buffer of 3 slots: fill it, wrap round to slot 0, get refused when full, then empty it.
capacity = 3; push(11), push(22), shift(), push(33), push(44), push(55), shift(), shift(), shift(), shift()
| Step | Call | Returns | items | head | size | Why |
|---|---|---|---|---|---|---|
| 1 | push(11) | true | [11, null, null] | 0 | 1 | tail = (0 + 0) % 3 = 0, so 11 goes into slot 0. |
| 2 | push(22) | true | [11, 22, null] | 0 | 2 | tail = (0 + 1) % 3 = 1. |
| 3 | shift() | 11 | [null, 22, null] | 1 | 1 | The oldest item is at head = 0. Its slot is cleared and head moves to 1. |
| 4 | push(33) | true | [null, 22, 33] | 1 | 2 | tail = (1 + 1) % 3 = 2, the last slot. |
| 5 | push(44) | true | [44, 22, 33] | 1 | 3 | tail = (1 + 2) % 3 = 0: the index wraps round and 44 reuses the slot 11 left. |
| 6 | push(55) | false | [44, 22, 33] | 1 | 3 | size === capacity, so the push is refused and nothing changes. |
| 7 | shift() | 22 | [44, null, 33] | 2 | 2 | The oldest item is in slot 1, not slot 0. Order comes from head, not from array position. |
| 8 | shift() | 33 | [44, null, null] | 0 | 1 | head = (2 + 1) % 3 = 0: head wraps round too. |
| 9 | shift() | 44 | [null, null, null] | 1 | 0 | The newest item, stored in slot 0, comes out last, as a queue should. |
| 10 | shift() | null | [null, null, null] | 1 | 0 | size === 0, so there's nothing to remove. |
The items came out as 11, 22, 33, 44, the order they went in, while the array never grew past 3 slots. 55 was refused because the buffer was full at that moment.
Compared with other ways
When to pick something else
| Approach | Time | Extra memory | Choose it when |
|---|---|---|---|
| Ring buffer | O(1) | O(capacity) | You know the most items that can wait at once, or only the newest capacity items matter, and memory must not grow. |
Array with shift | O(n) | O(n) | Tiny queues where the clearest code matters most. Each dequeue moves all remaining items. |
Array with a head index | O(1) | O(n) | The queue has no size limit and runs for a short time, so the served slots never get large. |
| Linked-list queue | O(1) | O(n) | The queue has no size limit and must free served items, at the cost of one node object per item. |
Extra memory: O(capacity): the array is created once with capacity slots, however many items pass through it over time.
How the steps grow
- Ring buffer, Array with a
headindex, Linked-list queueO(1) - Array with
shiftO(n)
| Approach | n = 10 | n = 1,000 | n = 1,000,000 |
|---|---|---|---|
| Ring buffer | 1 | 1 | 1 |
Array with shift | 10 | 1,000 | 1 million |
Array with a head index | 1 | 1 | 1 |
| Linked-list queue | 1 | 1 | 1 |
Inputs that need care
5 tricky inputs
pushinto a full buffer- It returns
falseand changes nothing, aspush(55)did in the trace. The caller must decide to drop the value, wait, or use the overwriting version. shiftfrom an empty buffer- It returns
nullwithout touchinghead. - Pushing
nullas a real value shiftwould returnnull, which looks the same as "empty". Checksizeinstead of the return value, or return anundefinedmarker that can't be stored.capacity = 1(head + size) % 1is always0, so the single slot is reused for every item. It works as a one-item queue.capacity = 0size === capacityis true at once, so everypushreturnsfalseand everyshiftreturnsnull. The% 0line is never reached, so it never producesNaN.
Variations and follow-ups
Twists interviewers add
Overwrite the oldest when full
Logs and metrics want the newest data. When size === capacity, write the new value into slot head and move head one slot forward. The oldest item is dropped and size stays at capacity.
Two indexes and one empty slot
Some versions keep head and tail and no size. Then head === tail must mean empty, so the buffer is called full when (tail + 1) % capacity === head, leaving one slot unused.
Power-of-two capacity
If capacity is a power of two, such as 1,024, x % capacity equals x & (capacity - 1), a single bit operation. Audio and network code often picks such sizes for that reason.
Shared between threads
With one writer and one reader, such as an AudioWorklet and the main thread, the slots can live in a SharedArrayBuffer, and each side moves only its own index with Atomics. Neither side ever allocates memory while data flows.
In an interview
Spot it, explain it, answer follow-ups
- Spot it from "fixed size", "circular", "keep the last
k", "moving average of the lastkvalues", or "design a circular queue". - Explain the state in one sentence: "
headis the oldest item's slot,sizeis how many items there are, and the free slot is(head + size) % capacity". Then say why you tracksize: with only two indexes, empty and full look the same. - Test by hand on a buffer of 3: fill it, remove one, then push twice so the index wraps to slot
0. Check the refused push and the order items come out. - Expect the follow-up "what if it should keep the newest data when full?". Overwrite slot
headand moveheadforward, leavingsizeatcapacity.
Where frontend code uses it
The same idea in real UI code
- Error-reporting tools keep only the last few dozen user actions (breadcrumbs) before a crash; a ring buffer stores exactly the newest
kwithout growing. - Audio code that passes samples between an
AudioWorkletand the main thread commonly uses a ring buffer over aSharedArrayBuffer, so neither side allocates memory while sound plays. - FPS meters and performance overlays keep the last 60 or 120 frame times in a ring buffer to draw a rolling graph.