01 / The idea
Keep the newest. Let the oldest go.
A weather station takes a reading every minute. The panel on its screen shows the last few readings and their average. Nobody wants every reading since the station was switched on, and the device does not have the memory for them anyway. The job is to keep the latest handful and quietly let the oldest one go.
You have written this. [query, ...recent].slice(0, 5) keeps five recent
searches. A log viewer that trims to its last thousand lines does it too. The scrollback in
VS Code’s terminal does it with what xterm.js calls a circular list: a maximum number of lines, where each new line past the limit overwrites the oldest.
A ring buffer, also called a circular buffer, is that structure: a fixed row of slots with a write position that wraps from the last slot back to the first. When every slot is full, it has to choose. It can overwrite the oldest value, or refuse the new one. This lesson builds one and makes that choice on purpose.
02 / Name the rule
Remember where to write next, and how many you have.
A ring buffer keeps two numbers beside its slots. The write position is the
slot the next value goes into. The length is how many values it holds.
After each write, the write position moves one slot on, and past the last slot it wraps to
slot 0. The oldest value sits length slots behind the write position, at (write − length) mod capacity.
The invariant: the values fill the length slots just behind the write position,
oldest first, with 0 ≤ length ≤ capacity. The capacity never changes. Taking
the oldest value clears its slot and shortens the length, and the write position stays put.
When the buffer is full, the write position lands on the oldest value. That is where the policy comes in. Overwrite puts the new value there and lets the old one go. Reject leaves everything alone and tells the caller no. Neither keeps everything, and that is the point of a fixed size.
How is this different from the deque’s ring?Same wrap, different promise
The Queue and deque lesson used a ring too, and it grew when it filled, so no track was ever lost. A ring buffer never grows. Its memory is fixed from the start, so a full buffer must drop something.
Growing is the right answer when every item matters. A fixed size is the right answer when only the recent ones do, or when memory must have a ceiling.
03 / Follow one operation
The fifth reading has nowhere new to go.
Three readings sit in four slots: 18.0 °C at 09:00, then 19.0 °C twice, at 09:01 and 09:02. The same temperature twice is still two readings, which is why each one carries its time. The 09:03 reading takes the last free slot, and the write position wraps around to slot 0, which holds the oldest reading.
Before you watch, predict the average after 09:04 arrives at 21.0 °C. The animation replays each recorded write, eviction, and move of the write position. Try it lets you switch the policy and record your own readings.
Keep the latest four readings.
- Oldest → newest
- 09:00 18.0 → 09:01 19.0 → 09:02 19.0
- Average
- 18.67 °C
- 0 09:00 18.0 oldest
- 1 09:01 19.0
- 2 09:02 19.0
- 3 · next write
After slot 3 comes slot 0.
3 readings in 4 slots · next write slot 3
Three readings. One slot free.
09:00, 09:01, and 09:02 fill slots 0 to 2, and the next reading goes to slot 3. The two 19.0 °C readings are different readings: look at their times.
Reduced motion: choose a scene to see its completed state.
Read this scene
09:00, 09:01, and 09:02 fill slots 0 to 2, and the next reading goes to slot 3. The two 19.0 °C readings are different readings: look at their times.
09:00, 09:01, and 09:02 fill slots 0 to 2, and the next reading goes to slot 3. The two 19.0 °C readings are different readings: look at their times.
Next write slot 3. Average 18.67 °C.
- Slot 0: 09:00, 18.0 °C
- Slot 1: 09:01, 19.0 °C
- Slot 2: 09:02, 19.0 °C
- Slot 3: empty
Watch restarts when you return. Step through keeps your selected step. Try it starts a fresh buffer each time you open it.
04 / Read the shape
The buffer holds the window. The station decides what counts.
Basic form is the ring buffer alone: push, take the oldest, read by age,
and a policy for when it is full. In the wild wraps it in RecentReadings, which owns what a station cares about. Readings outside −50.0
to 60.0 °C are sensor glitches and never enter the buffer. A running sum makes the average a
single division. The same class can overwrite or refuse.
Notice where the glitch check lives. The buffer would happily store 99.9 °C and let it push out a real reading. The station turns it away before the buffer ever sees it.
A ring buffer: a fixed row of slots, the slot the next write goes to, and a length. When it fills, its policy decides: overwrite the oldest value, or refuse the new one. The optional trace records every write, eviction, refusal, and move of the write position.
export type Policy = 'overwrite' | 'reject';
export type Step = {
kind: 'write' | 'evict' | 'reject' | 'advance' | 'read' | 'clear' | 'missing';
/** A slot index, the index asked for when missing, or -1 for none. */
from: number;
to: number;
};
export type Pushed<T> =
{ outcome: 'stored' } | { outcome: 'overwrote'; dropped: T } | { outcome: 'rejected' };
export type Lookup<T> = { found: true; value: T } | { found: false };
// A fixed row of slots. `write` is where the next value goes, and it wraps to
// slot 0 after the last slot. The oldest value sits `length` slots behind it.
export class RingBuffer<T> {
#slots: (T | undefined)[];
#write = 0;
#length = 0;
#policy: Policy;
#steps: Step[] = [];
#capture: boolean;
constructor(capacity: number, policy: Policy, capture = false) {
if (!Number.isInteger(capacity) || capacity < 1)
throw new RangeError('Capacity must be a whole number of at least 1.');
this.#slots = new Array(capacity).fill(undefined);
this.#policy = policy;
this.#capture = capture;
}
get length(): number {
return this.#length;
}
get capacity(): number {
return this.#slots.length;
}
get policy(): Policy {
return this.#policy;
}
/** The slot the next value will be written to. Exposed for inspection. */
get write(): number {
return this.#write;
}
// When full, the policy decides: overwrite the oldest, or refuse the new value.
push(value: T): Pushed<T> {
this.#steps = [];
const slot = this.#write;
if (this.#length === this.capacity) {
if (this.#policy === 'reject') {
this.#record('reject', slot, -1);
return { outcome: 'rejected' };
}
const dropped = this.#slots[slot] as T; // when full, the write slot holds the oldest
this.#record('evict', slot, -1);
this.#put(slot, value);
return { outcome: 'overwrote', dropped };
}
this.#length++;
this.#put(slot, value);
return { outcome: 'stored' };
}
takeOldest(): Lookup<T> {
this.#steps = [];
if (this.#length === 0) {
this.#record('missing', -1, -1);
return { found: false };
}
const slot = this.#slotOf(0);
const value = this.#slots[slot] as T;
this.#record('read', slot, slot);
this.#slots[slot] = undefined; // Let go of the value.
this.#length--;
this.#record('clear', slot, -1);
return { found: true, value };
}
// Index 0 is the oldest value; length - 1 is the newest.
at(index: number): Lookup<T> {
this.#steps = [];
if (!Number.isInteger(index) || index < 0 || index >= this.#length) {
this.#record('missing', index, -1);
return { found: false };
}
const slot = this.#slotOf(index);
this.#record('read', slot, slot);
return { found: true, value: this.#slots[slot] as T };
}
// Oldest to newest. The outer array is a copy; stored objects are not cloned.
values(): T[] {
return Array.from({ length: this.#length }, (_, i) => this.#slots[this.#slotOf(i)] as T);
}
trace(): Step[] {
return this.#steps.map((step) => ({ ...step }));
}
#slotOf(index: number): number {
return (this.#write - this.#length + index + this.capacity) % this.capacity;
}
#put(slot: number, value: T): void {
this.#slots[slot] = value;
this.#record('write', -1, slot);
this.#write = (slot + 1) % this.capacity;
this.#record('advance', slot, this.#write);
}
#record(kind: Step['kind'], from: number, to: number): void {
if (this.#capture) this.#steps.push({ kind, from, to });
}
} type Policy string
const (
Overwrite Policy = "overwrite"
Reject Policy = "reject"
)
type Step struct {
Kind string `json:"kind"`
From int `json:"from"`
To int `json:"to"`
}
// A fixed row of slots. write is where the next value goes, and it wraps to
// slot 0 after the last slot. The oldest value sits length slots behind it.
type RingBuffer[T any] struct {
slots []T
write int
length int
policy Policy
steps []Step
capture bool
}
func NewRingBuffer[T any](capacity int, policy Policy, capture bool) *RingBuffer[T] {
if capacity < 1 {
panic("ring buffer capacity must be at least 1")
}
return &RingBuffer[T]{slots: make([]T, capacity), policy: policy, capture: capture}
}
func (b *RingBuffer[T]) Len() int { return b.length }
func (b *RingBuffer[T]) Capacity() int { return len(b.slots) }
// Write is the slot the next value will be written to. Exposed for inspection.
func (b *RingBuffer[T]) Write() int { return b.write }
// Push returns "stored", "overwrote" with the dropped value, or "rejected".
// When full, the policy decides: overwrite the oldest, or refuse the new value.
func (b *RingBuffer[T]) Push(value T) (outcome string, dropped T) {
b.steps = nil
slot := b.write
if b.length == len(b.slots) {
if b.policy == Reject {
b.record("reject", slot, -1)
return "rejected", dropped
}
dropped = b.slots[slot] // when full, the write slot holds the oldest
b.record("evict", slot, -1)
b.put(slot, value)
return "overwrote", dropped
}
b.length++
b.put(slot, value)
return "stored", dropped
}
func (b *RingBuffer[T]) TakeOldest() (T, bool) {
b.steps = nil
var zero T
if b.length == 0 {
b.record("missing", -1, -1)
return zero, false
}
slot := b.slotOf(0)
value := b.slots[slot]
b.record("read", slot, slot)
b.slots[slot] = zero // Release the value's reference, if T holds one.
b.length--
b.record("clear", slot, -1)
return value, true
}
// At reads by age: index 0 is the oldest value; Len()-1 is the newest.
func (b *RingBuffer[T]) At(index int) (T, bool) {
b.steps = nil
if index < 0 || index >= b.length {
b.record("missing", index, -1)
var zero T
return zero, false
}
slot := b.slotOf(index)
b.record("read", slot, slot)
return b.slots[slot], true
}
// Values returns the values oldest to newest, in a new slice.
func (b *RingBuffer[T]) Values() []T {
values := make([]T, b.length)
for i := range values {
values[i] = b.slots[b.slotOf(i)]
}
return values
}
func (b *RingBuffer[T]) Trace() []Step { return append([]Step{}, b.steps...) }
func (b *RingBuffer[T]) slotOf(index int) int {
return (b.write - b.length + index + len(b.slots)) % len(b.slots)
}
func (b *RingBuffer[T]) put(slot int, value T) {
b.slots[slot] = value
b.record("write", -1, slot)
b.write = (slot + 1) % len(b.slots)
b.record("advance", slot, b.write)
}
func (b *RingBuffer[T]) record(kind string, from, to int) {
if b.capture {
b.steps = append(b.steps, Step{kind, from, to})
}
} Reading the TypeScriptA tagged result for each outcome
push returns stored, overwrote with the dropped
value, or rejected, so a caller cannot miss that something fell out.
Temperatures are whole numbers of tenths of a degree, so the running sum stays exact and
the average is the only division. readings() copies each reading on the way out.
Reading the GoOutcomes as strings, and zeroed slots
Push returns an outcome and the dropped value, which is the zero value
unless it overwrote. TakeOldest writes the zero value into the slot it empties,
so the buffer does not keep a reference alive.
NewRingBuffer panics on a capacity below 1, because a buffer with no slots can
hold nothing. Create these with their constructors and pass pointers.
What would I normally use in application code?Most standard libraries leave this one to you
In Go, container/ring is a
circular linked list of a fixed length: store a value, then move to Next. A slice with a write position, like this one, avoids a node for every
value.
In TypeScript, a small class like this one, or a well-tested package. For a handful of
entries, slice is simpler and fast enough.
In Rust, a VecDeque with a pop_front before each push_back once it reaches your
limit gives you the overwrite policy. with_capacity reserves space for at least
that many values, so the limit is still your own check.
05 / Try a decision
A full buffer always loses something.
Overwrite loses the oldest value. Reject loses the newest. Neither is wrong on its own; the question is which loss the caller can live with. Here are two buffers on the same station, full at the same moment.
06 / Follow the cost
A week of readings, the same four slots.
Here is every operation at a glance, for a buffer holding n readings. The rest of this section is about two rows: the memory, and the average.
| Operation | Time | Extra space | What it assumes |
|---|---|---|---|
| Record a reading | O(1) | O(1) | One write and one move of the write position. When full, overwrite adds one eviction, and reject changes nothing. |
| Take the oldest | O(1) | O(1) | Clears one slot. The write position does not move. |
| Read by age | O(1) | O(1) | The i-th oldest is in slot (write − length + i) mod capacity. |
| Rolling average | O(1) | O(1) | The running sum already includes the new reading and excludes the dropped one. Adding up the window again would be O(n). |
| Copy oldest to newest for display | O(n) | O(n) | Walks from the oldest slot, wrapping at the end, and copies every reading. |
| Hold the readings | — | O(capacity) | Fixed when the buffer is created. It does not depend on how many readings have ever arrived. |
Leave the station running for a week. That is 10,080 readings, and the buffer still holds four. Every reading after the fourth did the same three things: one eviction, one write, and one move of the write position. Nothing was allocated and nothing shifted. The memory was decided once, when the capacity was chosen.
Now the average. Adding up four readings once a minute is cheap. Adding up the last hour of one-per-second readings, 3,600 of them, every second, is not. The station never adds the window up again. When 09:04 replaced 09:00, the sum gained 210 tenths and lost 180, and the average became that sum over four. That is one addition and one subtraction, whatever the capacity.
Compare the version you have probably written. readings.push(r); if (readings.length > 3600) readings.shift(); also keeps
the latest 3,600, but the ECMAScript specification describes shift as moving every
remaining element. Once the list is full, every new reading moves the 3,600 that remain after
the oldest leaves.
These counts are slot reads and writes, not milliseconds. Copying the readings for display and drawing them are extra. And a fixed size is a promise to lose data once the buffer is full. The table does not show that cost, but the people reading the chart will.
07 / Give it a real job
One window per sensor, sized on purpose.
In a real station, the sensor driver calls record once a minute, and the
display asks for readings() and average() when it redraws. There
is one RecentReadings per sensor. Its capacity comes from a requirement, such as “the last
hour at one reading a minute,” not from a guess.
What it leaves out is a decision too. It does not persist readings, so a restart empties it. It assumes one caller at a time; a sensor interrupt and a display thread need a lock, or a design built for one writer and one reader. It trusts the times it is given and does not sort late readings into place. And it cannot report the window’s minimum or maximum in O(1); that needs another structure kept beside it.
Overwrite fits a display, where only recent readings matter. For readings that must reach a server, refusing new ones and raising an alert is safer. And the honest answer to “never lose a reading” is storage that grows, such as a file.
Build UIs?See the ring buffer already in your components, and the day you have to own one.
Where it already is in your components
Every “recent” list you have capped follows this policy. [query, ...recent].slice(0, 5) keeps the newest five searches and lets the oldest
fall off the end, which is exactly what an overwriting ring buffer does. It copies the list
to get there, and for five strings that is the right call: simple, immutable, and cheap.
The browser makes the opposite choice in one place you rely on. Resource Timing, which records how
long each request took, starts with room for at least 250 entries. Once that buffer is
full, new timings stop going into it and the browser fires a resourcetimingbufferfull event. Unless a listener makes room, the new timings are
dropped and the oldest stay. Same fixed size, opposite policy.
When you have to own it
Remember the deploy log from the dynamic array lesson. Lines stream in, you append them in place, and you publish once per frame. That lesson left you with one decision to make: what to drop once the log outgrows what you want to keep. A long build can print hundreds of thousands of lines, and holding all of them costs memory and rendering time for lines nobody will scroll back to.
So keep the tail. Give the log a ring buffer of 5,000 lines. Each new line is one write in
place, and once the buffer is full the oldest line falls away. push plus shift would move 5,000 lines for every line that arrives. Once per frame, copy
the lines out oldest to newest and render them, keyed by each line’s own id.
Notice what the reader loses: the start of the log. That is a product decision, not a detail. Say so on screen, with a line like “showing the last 5,000 lines,” and link the full log.
The recent-searches box you have written: newest first, at most five. The oldest falls off the end, which is a ring buffer’s overwrite policy, done by copying.
import { useState } from 'react';
const MAX_RECENT = 5;
export function SearchBox({ onSearch }: { onSearch: (query: string) => void }) {
const [query, setQuery] = useState('');
const [recent, setRecent] = useState<string[]>([]);
function search() {
const q = query.trim();
if (!q) return;
// Newest first, at most five. When a sixth arrives, the oldest falls off
// the end: a ring buffer's overwrite policy, done by copying the list.
// For five strings, copying is the right call. Dropping a repeat is this
// box's own rule, not the buffer's.
setRecent((previous) => [q, ...previous.filter((item) => item !== q)].slice(0, MAX_RECENT));
onSearch(q);
}
return (
<form
onSubmit={(event) => {
event.preventDefault();
search();
}}
>
<input value={query} onChange={(event) => setQuery(event.target.value)} />
<ul>
{recent.map((item) => (
<li key={item}>
<button type="button" onClick={() => setQuery(item)}>
{item}
</button>
</li>
))}
</ul>
</form>
);
}
08 / Make the call
Ask whether old data may go.
Reach for a ring buffer when only the latest entries matter and memory must not grow: recent readings, a log tail, the last few seconds of samples, an undo history with a limit. Decide the policy when you choose the size, not when the buffer fills.
Look elsewhere when the work changes shape. Every item must be kept: a queue or an array
that grows. The newest item first, not the oldest: a stack. The average of everything ever
seen, not just a window: no buffer at all, only a running total and a count. The minimum or
maximum of the window: a deque of candidates beside the buffer. Only a handful of entries:
an array and slice.
09 / Take the idea with you
Explain it without saying “ring buffer.”
“I have a fixed number of slots, and I remember where the next reading goes. Each reading goes there and I move on, wrapping at the end. When every slot is full, the next write lands on the oldest reading, so I either replace it or refuse the new one.” That describes the mechanism. The name is what you call it in a review.
Before moving on, explain three things without the name: why the oldest reading sits right
at the write position when the buffer is full, why the average needed no loop, and why the
uploader should refuse rather than overwrite. Then find a list in your own code that you cap
with slice, and name the policy it is using.
Connections to follow nextRelated lessons
- Queue and deque wraps the same way, and grows instead of dropping.
- Dynamic array grows when it fills. Its deploy log is where this lesson’s log tail began.
- Stack keeps history too, but takes the newest first.