01 / The idea
First come, first served. Unless you tap Play next.
You already know the rule, because you live by it. Tracks you add to the queue play in the order you added them. Then a song comes to mind that cannot wait, you tap Play next, and it jumps ahead of everything. The list takes from the front, and it accepts new tracks at both ends.
Your code runs on the same rule all day. React works through the updates you queue with setState in the order you made them. The browser resumes each await from its microtask queue, oldest first. And every time you reached for an
array and paired push with shift, you built a queue by hand.
A queue adds at the back and removes from the front: first in, first out. A deque, short for double-ended queue and said “deck,” adds and removes at
both ends. This lesson builds one on a ring of slots, the same idea as Rust’s VecDeque, so the next time a shift shows up on a long list you know
what it costs and what to use instead.
02 / Name the rule
Keep a front and a length, and let the slots wrap.
Start with a row of four slots. The deque remembers two numbers: the head,
which slot holds the front track, and the length, how many tracks there
are. The back track sits at (head + length − 1) % capacity. When an index runs
past the last slot, the % brings it back to slot 0, so the row behaves like a ring.
The invariant: the tracks fill slots head, head + 1, and onward, wrapping at the end, in
up-next order, with 0 ≤ length ≤ capacity. That is all it promises. The front
can sit in any slot, and the empty slots can be on either side of it.
That is the whole trick. Taking the front track clears one slot and moves the head forward. Adding at the front moves the head back one slot and writes there. No track between the ends ever moves.
Why not an array with push and shift?The call that moves everything
push adds at the back cheaply. But the ECMAScript specification describes shift as moving every remaining element down one index, and unshift as moving every element
up to make room. Engines sometimes find shortcuts for particular arrays, and you cannot count
on one.
For a list of ten tracks it does not matter. For a queue that stays long while work flows through it, every Next track pays for the whole list.
03 / Follow one operation
Both ends wrap, and one grow makes room.
Four tracks went into four slots, and Intro started playing. Its slot is empty now and the front sits at slot 1. Add Echo to the queue, and the back has nowhere to go but around, to slot 0. Then tap Play next on Fever, with every slot full.
Before you watch, guess which slot Fever lands in. The animation replays each recorded write, copy, and head move. Try it gives you the buttons.
Take the front. Add at either end.
4 slots
- 0 ·
- 1 Blue front
- 2 Coast
- 3 Drift back
After slot 3 comes slot 0.
3 tracks in 4 slots · front at slot 1
Three tracks up next.
Intro left slot 0 when it started playing. The front moved to slot 1, and Blue, Coast, and Drift stayed where they were.
Reduced motion: choose a scene to see its completed state.
Read this scene
Intro left slot 0 when it started playing. The front moved to slot 1, and Blue, Coast, and Drift stayed where they were.
Intro left slot 0 when it started playing. The front moved to slot 1, and Blue, Coast, and Drift stayed where they were.
Now playing: Intro. Front at slot 1, 3 tracks.
- Slot 0: empty
- Slot 1: Blue
- Slot 2: Coast
- Slot 3: Drift
Watch restarts when you return. Step through keeps your selected step. Try it starts a fresh player each time you open it.
04 / Read the shape
The ring provides the ends. The player decides which one.
Basic form is the ring deque alone: push and pop at either end, peek, and
grow. In the wild wraps it in UpNext, which owns the rules a
real player has. Add to queue uses the back and Play next uses the front. Next track stops
playback when nothing is left, and Undo may reverse only the most recent add.
Notice where the undo rule lives. The deque will pop either end whenever it is asked. The player remembers which end the last add used, and forgets it as soon as anything else happens.
A ring deque: a fixed row of slots, the slot the front is in, and a length. Both ends wrap around the row, and a full ring copies its entries, front first, into twice the slots. The optional trace records every write, clear, head move, and copy.
export type Step = {
kind: 'allocate' | 'copy' | 'switch' | 'head' | 'write' | 'read' | 'clear' | 'missing';
/** A slot index, a capacity for allocate and switch, or -1 for none. */
from: number;
to: number;
};
export type Lookup<T> = { found: true; value: T } | { found: false };
// Entries sit in slots head, head + 1, … and wrap past the last slot to 0.
// Neither end shifts the other entries. Only the head and the length move.
export class RingDeque<T> {
#slots: (T | undefined)[] = new Array(4).fill(undefined);
#head = 0;
#length = 0;
#steps: Step[] = [];
#capture: boolean;
constructor(capture = false) {
this.#capture = capture;
}
get length(): number {
return this.#length;
}
get capacity(): number {
return this.#slots.length;
}
/** The slot that holds the front entry. Exposed for inspection. */
get head(): number {
return this.#head;
}
pushBack(value: T): void {
this.#steps = [];
if (this.#length === this.capacity) this.#grow();
const slot = (this.#head + this.#length) % this.capacity;
this.#slots[slot] = value;
this.#length++;
this.#record('write', -1, slot);
}
pushFront(value: T): void {
this.#steps = [];
if (this.#length === this.capacity) this.#grow();
const head = (this.#head - 1 + this.capacity) % this.capacity;
this.#record('head', this.#head, head);
this.#head = head;
this.#slots[head] = value;
this.#length++;
this.#record('write', -1, head);
}
popFront(): Lookup<T> {
this.#steps = [];
if (this.#length === 0) return this.#missing();
const slot = this.#head;
const value = this.#take(slot);
this.#head = (slot + 1) % this.capacity;
this.#record('head', slot, this.#head);
return { found: true, value };
}
popBack(): Lookup<T> {
this.#steps = [];
if (this.#length === 0) return this.#missing();
return { found: true, value: this.#take(this.#backSlot()) };
}
peekFront(): Lookup<T> {
this.#steps = [];
return this.#length === 0 ? this.#missing() : this.#read(this.#head);
}
peekBack(): Lookup<T> {
this.#steps = [];
return this.#length === 0 ? this.#missing() : this.#read(this.#backSlot());
}
// Front to back. The outer array is a copy; stored objects are not cloned.
values(): T[] {
return Array.from(
{ length: this.#length },
(_, i) => this.#slots[(this.#head + i) % this.capacity] as T
);
}
trace(): Step[] {
return this.#steps.map((step) => ({ ...step }));
}
#backSlot(): number {
return (this.#head + this.#length - 1) % this.capacity;
}
#read(slot: number): Lookup<T> {
this.#record('read', slot, slot);
return { found: true, value: this.#slots[slot] as T };
}
#take(slot: number): T {
const value = this.#slots[slot] as T;
this.#record('read', slot, slot);
this.#slots[slot] = undefined; // Let go of the entry.
this.#length--;
this.#record('clear', slot, -1);
return value;
}
#missing(): Lookup<T> {
this.#record('missing', -1, -1);
return { found: false };
}
// Full: copy front to back into twice as many slots, starting at slot 0.
#grow(): void {
const previous = this.capacity;
const next: (T | undefined)[] = new Array(previous * 2).fill(undefined);
this.#record('allocate', previous, next.length);
for (let i = 0; i < this.#length; i++) {
const from = (this.#head + i) % previous;
next[i] = this.#slots[from];
this.#record('copy', from, i);
}
this.#slots = next;
this.#head = 0;
this.#record('switch', previous, next.length);
}
#record(kind: Step['kind'], from: number, to: number): void {
if (this.#capture) this.#steps.push({ kind, from, to });
}
} type Step struct {
Kind string `json:"kind"`
From int `json:"from"`
To int `json:"to"`
}
// Entries sit in slots head, head+1, … and wrap past the last slot to 0.
// Neither end shifts the other entries. Only the head and the length move.
type RingDeque[T any] struct {
slots []T
head int
length int
steps []Step
capture bool
}
func NewRingDeque[T any](capture bool) *RingDeque[T] {
return &RingDeque[T]{slots: make([]T, 4), capture: capture}
}
func (d *RingDeque[T]) Len() int { return d.length }
func (d *RingDeque[T]) Capacity() int { return len(d.slots) }
// Head is the slot that holds the front entry. Exposed for inspection.
func (d *RingDeque[T]) Head() int { return d.head }
func (d *RingDeque[T]) PushBack(value T) {
d.steps = nil
if d.length == len(d.slots) {
d.grow()
}
slot := (d.head + d.length) % len(d.slots)
d.slots[slot] = value
d.length++
d.record("write", -1, slot)
}
func (d *RingDeque[T]) PushFront(value T) {
d.steps = nil
if d.length == len(d.slots) {
d.grow()
}
head := (d.head - 1 + len(d.slots)) % len(d.slots)
d.record("head", d.head, head)
d.head = head
d.slots[head] = value
d.length++
d.record("write", -1, head)
}
func (d *RingDeque[T]) PopFront() (T, bool) {
d.steps = nil
if d.length == 0 {
return d.missing()
}
slot := d.head
value := d.take(slot)
d.head = (slot + 1) % len(d.slots)
d.record("head", slot, d.head)
return value, true
}
func (d *RingDeque[T]) PopBack() (T, bool) {
d.steps = nil
if d.length == 0 {
return d.missing()
}
return d.take(d.backSlot()), true
}
func (d *RingDeque[T]) PeekFront() (T, bool) {
d.steps = nil
if d.length == 0 {
return d.missing()
}
return d.read(d.head), true
}
func (d *RingDeque[T]) PeekBack() (T, bool) {
d.steps = nil
if d.length == 0 {
return d.missing()
}
return d.read(d.backSlot()), true
}
// Values returns the entries front to back, in a new slice.
func (d *RingDeque[T]) Values() []T {
values := make([]T, d.length)
for i := range values {
values[i] = d.slots[(d.head+i)%len(d.slots)]
}
return values
}
func (d *RingDeque[T]) Trace() []Step { return append([]Step{}, d.steps...) }
func (d *RingDeque[T]) backSlot() int { return (d.head + d.length - 1) % len(d.slots) }
func (d *RingDeque[T]) read(slot int) T {
d.record("read", slot, slot)
return d.slots[slot]
}
func (d *RingDeque[T]) take(slot int) T {
value := d.slots[slot]
d.record("read", slot, slot)
var zero T
d.slots[slot] = zero // Release the entry's reference, if T holds one.
d.length--
d.record("clear", slot, -1)
return value
}
func (d *RingDeque[T]) missing() (T, bool) {
d.record("missing", -1, -1)
var zero T
return zero, false
}
// Full: copy front to back into twice as many slots, starting at slot 0.
func (d *RingDeque[T]) grow() {
previous := len(d.slots)
next := make([]T, previous*2)
d.record("allocate", previous, len(next))
for i := 0; i < d.length; i++ {
from := (d.head + i) % previous
next[i] = d.slots[from]
d.record("copy", from, i)
}
d.slots, d.head = next, 0
d.record("switch", previous, len(next))
}
func (d *RingDeque[T]) record(kind string, from, to int) {
if d.capture {
d.steps = append(d.steps, Step{kind, from, to})
}
} Reading the TypeScriptPrivate slots and presence
RingDeque<T> keeps #slots, #head, and #length private. Lookup<T> is a tagged result, so an
empty end is distinguishable from a stored undefined.
values() copies the tracks front to back into a new array; it does not
clone the objects inside. UpNext copies each track on the way in and on the way
out, so a caller cannot rename a queued track from outside.
Reading the GoA slice that never appends
make([]T, 4) gives four slots, and the ring never calls append on them, so the slice’s length is the capacity. A separate length counts the tracks. (T, bool) reports presence.
Popping writes the zero value into the slot it leaves, so the queue does not keep a reference alive. Create these with their constructors and pass pointers; copying the struct copies the bookkeeping.
What would I normally use in application code?Some languages ship one
Go’s standard library has no deque. A slice is fine for a short queue, and container/list is a doubly linked
list if you need both ends. Many teams write a small ring like this one.
In TypeScript, an array with push and shift is fine while the queue
stays short. Past that, use a ring like this one or a well-tested deque package.
Rust’s standard library has one: VecDeque, which its documentation describes as a double-ended queue implemented with a growable
ring buffer: the structure you just built.
05 / Try a decision
Undo has to know which end.
Undo sounds simple: remove the track you just added. But the same song can be queued twice, and the list alone cannot tell you which copy is new. Decide before the feedback does.
06 / Follow the cost
Taking the front is one slot, not the whole list.
Here is every operation at a glance, with n tracks up next. The rest of this section is about the row that surprises people: taking from the front.
| Operation | Time | Extra space | What it assumes |
|---|---|---|---|
| Peek at either end | O(1) | O(1) | Reads one slot: the head, or head + length − 1 wrapped around. |
| Push back or push front | O(1) amortized | O(1) amortized | Writes one slot and moves the head at most one step. A full ring first copies all n tracks into twice the slots, which is O(n) for that one push. |
| Pop front or pop back | O(1) | O(1) | Clears one slot and moves the head at most one step. No other track moves. |
| Undo the most recent add | O(1) | O(1) | One pop, from the end the add used. Searching the list for the track instead would be O(n). |
| Copy up next for display | O(n) | O(n) | Walks from the head, wrapping at the end, and copies every track. |
| Hold n tracks | — | O(peak n) | Capacity is 4 slots, or under twice the most tracks the queue has held. The ring never shrinks. |
Go back to the last chapter. Fever started playing, and exactly three things happened: slot 7 was read, slot 7 was cleared, and the head moved from 7 to 0. Blue, Coast, Drift, and Echo stayed in slots 0 to 3. With a thousand tracks queued, Next track would still read one slot, clear one slot, and change one number.
Now do the same with an array and shift. The specification moves every
remaining element down one index: 999 moves for a thousand tracks. Play the whole list and
the moves add up to about n²/2, half a million for a thousand tracks. unshift for Play next is the same story in the other direction.
Growing costs what it cost in the dynamic array: an O(n) copy that doubling spreads across the pushes that follow, so a push is amortized O(1). The one difference is where the copy starts. The ring copies from the head, wrapping as it goes, so the new ring always begins with the front track in slot 0.
These are slot reads and writes, not milliseconds. Allocation, copying the list for display,
the trace, and drawing are extra. And for a short list, shift on a plain array is
fast enough, and simpler.
07 / Give it a real job
One queue per listening session.
In a real player, one listening session owns the up-next list. The buttons call addToQueue, playNext, and undoAdd. The audio engine
calls nextTrack when a song ends. The screen asks for a copy with upNext() and renders it.
What UpNext leaves out is a design decision too. It forgets what already played,
so a Back button needs its own history, and that is a stack. It has no shuffle, no repeat, no
saving across restarts, and no syncing between your phone and your laptop. It also assumes one
caller at a time. Two devices tapping Play next at once need one owner that puts their requests
in order.
Dragging a track into the middle of the list is the move a deque does not help with. Inserting between the ends shifts every track on one side, exactly as an array would. If reordering is the main event, look at the choices below.
Build UIs?See the queue already inside your components, and the day you have to own one.
Where it already is in your components
Write a button that calls setCount(count + 1) three times, and the count goes
up by one. Change it to setCount(n => n + 1) three times, and it goes up
by three. React’s documentation explains why. Each call adds an update to a queue, and during the next render React works
through that queue in order. A value means “replace with this,” so the last replacement wins.
A function receives the result of the update before it, so the three add up. On the hook itself,
React keeps those pending updates in a circular linked list.
Svelte keeps no update queue for state. count += 1 three times adds three,
because each assignment takes effect immediately and the next line reads the new value.
What waits is the DOM: Svelte applies the changes together in a microtask, which is why tick() exists. Same button, and the queue lives in a different place.
When you have to own it
Picture a notes app that works offline. Every edit is saved locally, then sent to the server in the order it was made, because an older edit that lands last can overwrite a newer one. So edits wait in a queue, and one at a time goes over the wire.
Then a send fails. That edit has to go back to the front, ahead of
everything typed since, or the order breaks. That is a deque, not a queue. And after a day
offline on a train, the outbox can hold thousands of edits. shift would move all
of them for every edit sent. The ring changes one number.
Notice the one-at-a-time rule in the sample. That rule is what keeps the order. The deque only keeps it cheap.
Three updates from one click. React queues them and works through them in order at the next render; Svelte applies each assignment at once and batches only the DOM work.
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
function addThreeByValue() {
// Each call adds "replace with count + 1" to the hook's update queue.
// count is still 0 in this render, so all three say "replace with 1".
// React works through the queue at the next render: count becomes 1.
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
}
function addThreeByUpdater() {
// Each call adds a function to the queue instead. At the next render
// React runs them in the order they were queued, handing each one the
// result of the one before: count goes up by 3.
setCount((n) => n + 1);
setCount((n) => n + 1);
setCount((n) => n + 1);
}
return (
<>
<output>{count}</output>
<button onClick={addThreeByValue}>+3 (adds 1)</button>
<button onClick={addThreeByUpdater}>+3</button>
</>
);
}
08 / Make the call
Ask which ends your workload touches.
Reach for a queue when work must come out in the order it went in. Reach for a deque when you also need the other end: a retry that keeps its place, a Play next, an undo of the last add. Use your language’s built-in one when it has one.
Look elsewhere when the work changes shape. A short list: a plain array, and do not worry
about shift. The most recent item first, not the oldest: a stack. The most
urgent item first: a binary heap. Moving items around in the middle, like dragging tracks: a
linked list with handles, or an array if the list is short. Only the latest few, dropping
the oldest: a fixed-size ring buffer.
09 / Take the idea with you
Explain it without saying “deque.”
“The tracks sit in a row of slots that wraps around. I keep the slot of the first one and a count. Taking the first clears its slot and moves the start forward. Adding at the front moves the start back. When every slot is full, I copy them in order into a row twice as long.” That describes the mechanism. The name is what you call it in a review.
Before moving on, explain three things without the name: why taking the front track moves no
other track, why Fever landed in slot 7, and why Undo after Play next has to pop the front.
Then find a shift() in your own code and ask whether the list behind it can grow.
Connections to follow nextRelated lessons
- Dynamic array grows the same way. A deque adds a front that can move.
- Stack takes from the end it adds to. A deque can play either role.
- Binary heap takes the most urgent item next, not the oldest.
- Ring buffer keeps a fixed number of slots and overwrites or refuses when full.