AniUI Academy

Stacks and Queues in Real UI Code

LIFO and FIFO are not abstract vocabulary — they're the exact discipline behind undo history, browser navigation, toast notification order, and a print or upload queue.

8 min read

Stacks and queues aren't separate data structures so much as two disciplines for the order you take things back out of a collection — and both already appeared, unnamed, earlier in this course: the call stack (recursion), DFS (a stack), BFS (a queue). This lesson names them properly and connects them to UI features you've almost certainly built.

Stack: last in, first out (LIFO)

A stack only allows adding and removing from one end — the "top." The last thing pushed on is the first thing that comes back off.

class Stack {
  #items = [];
  push(item) { this.#items.push(item); }        // O(1) amortized
  pop() { return this.#items.pop(); }             // O(1)
  peek() { return this.#items.at(-1); }
  get isEmpty() { return this.#items.length === 0; }
}

Undo history is the clearest real-world stack. The requirement — undo the most recently made change first — is the literal definition of LIFO:

const undoStack = new Stack();
undoStack.push({ action: "bold", target: "paragraph-1" });
undoStack.push({ action: "delete", target: "image-3" });
 
const lastAction = undoStack.pop(); // { action: "delete", ... } — the most recent one, reversed first

Browser back/forward navigation is two stacks working together: going back pops from a "back" stack and pushes the current page onto a "forward" stack; navigating to a brand-new page after going back pushes onto "back" but clears "forward" entirely — the old forward path no longer connects to anything reachable from where you are now. This is the identical discard-the-redo-branch shape from the linked-list undo/redo example in the previous lesson, just implemented with two stacks instead of one doubly linked chain — a good reminder that the same underlying discipline can be built on more than one concrete structure.

Queue: first in, first out (FIFO)

A queue adds at one end and removes from the other — the first thing added is the first thing that comes back out.

class Queue {
  #items = [];
  enqueue(item) { this.#items.push(item); }        // O(1) amortized — adds to the back
  dequeue() { return this.#items.shift(); }          // O(n) — see below!
  get isEmpty() { return this.#items.length === 0; }
}

A toast notification queue — show notifications in the order they were triggered, dismissing the oldest first to make room — is the natural FIFO case: the first toast shown should be the first one to go, not the most recent.

A task queue or upload queue (process items in the order they were added, one at a time) is the same discipline: enqueue on arrival, dequeue when a worker is free to process the next one.

The real gotcha: shift() is O(n)

The Queue class above works correctly, but has a genuine performance issue worth flagging explicitly: Array.prototype.shift() removes the first element, which means every remaining element's index has to shift down by one — O(n), not O(1). For a queue processed occasionally, this is invisible. For a queue processed constantly (a busy notification system, a real-time task processor), it's the same "cheap-looking operation, called repeatedly" cost that's been the recurring theme of this course.

The fix: track a separate head index instead of physically removing elements, only actually cleaning up the consumed prefix occasionally (or using a proper ring buffer, which is the standard real solution and is what production queue implementations use under the hood):

class FastQueue {
  #items = [];
  #head = 0;
 
  enqueue(item) { this.#items.push(item); }             // O(1) amortized
 
  dequeue() {
    if (this.#head >= this.#items.length) return undefined;
    const item = this.#items[this.#head];
    this.#items[this.#head] = undefined; // let it be garbage-collected
    this.#head++;                          // O(1) — just move the pointer
    return item;
  }
 
  get isEmpty() { return this.#head >= this.#items.length; }
}

Now dequeue is genuinely O(1) — advance a pointer instead of re-indexing the whole array. The trade: the underlying array only grows (the consumed prefix isn't reclaimed until you periodically compact it), which is the space-for-time trade-off theme from earlier in this course, made concrete once more.

Try it yourself
Loading playground...

What to remember

  • A stack is LIFO — last in, first out — and is the natural fit for undo history and browser back/forward navigation.
  • A queue is FIFO — first in, first out — and is the natural fit for notification ordering and task/upload processing.
  • push/pop (both at the array's end) implement a stack in genuine O(1) amortized time; push/shift implements a queue's behavior correctly but shift() costs O(n), a real gotcha for frequently-processed queues.
  • Tracking a separate head index (or using a ring buffer) fixes the shift() cost, achieving true O(1) dequeue at the cost of the underlying array only growing until periodically compacted.

Check yourself

4 questions · pass 3/4 to unlock The LRU Cache, with a Map and a Linked List

up to 50
  1. 1.Undo history is naturally a stack (LIFO — last in, first out), not a queue. Why?

  2. 2.A toast notification system should display notifications in the order they were triggered — the first toast shown should be the first one dismissed to make room. Which discipline does this call for, and what is the time complexity of adding and removing with the right JavaScript structure?

  3. 3.Browser back/forward navigation history behaves like which structure, and why does 'going back, then navigating to a brand-new page' discard the forward history?

  4. 4.Array.prototype.push()/pop() (both operate on the END) versus push()/shift() (add to the end, remove from the FRONT) implement which two structures respectively, and are both pairs O(1) in practice?

4 left to answer