← Data structures & algorithms
Sequences Fixed size, oldest out

Ring buffer

Keep only the latest.

The scrollback in VS Code’s terminal is one of these. So is every “last hour” chart, and every list of recent searches you have capped at five. Something keeps the newest entries and lets the oldest fall away, in memory that never grows. Let’s watch a weather station keep its last four readings, and decide what it should do when a fifth arrives.

TypeScriptGoOne weather station, two implementations.

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.

Ring buffer

Keep the latest four readings.

Oldest → newest
09:00 18.0 → 09:01 19.0 → 09:02 19.0
Average
18.67 °C
  1. 0 09:00 18.0 oldest
  2. 1 09:01 19.0
  3. 2 09:02 19.0
  4. 3 · next write

After slot 3 comes slot 0.

3 readings in 4 slots · next write slot 3

01/ 04
The setup

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.

TypeScriptReading
readings.ts
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 });
	}
}
GoAlongside
readings.go
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.

A greenhouse station keeps 60 readings in each of two ring buffers: one for the last-hour chart on its screen, and one for an uploader that sends readings to a server when Wi-Fi is up. Wi-Fi has been down for two hours, and both buffers are full. What should each do with the next reading?

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.

Ring buffer: time and extra space per operation
OperationTimeExtra spaceWhat it assumes
Record a readingO(1)O(1)One write and one move of the write position. When full, overwrite adds one eviction, and reject changes nothing.
Take the oldestO(1)O(1)Clears one slot. The write position does not move.
Read by ageO(1)O(1)The i-th oldest is in slot (write − length + i) mod capacity.
Rolling averageO(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 displayO(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.

ReactAlready in your code
SearchBox.tsx
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.