intermediateCommon6 min read · Updated Jul 18, 2026

Implement a Browser History (Back, Forward, Visit)

Implement a BrowserHistory class in JavaScript — the index-and-stack model, the truncation-on-visit rule, real History API differences, and how routers build on it.


The problem

Implement BrowserHistory: visit(url) navigates somewhere new, back() and forward() move through history, current() reports where you are — with the exact semantics your browser's back button has.

This is a pure data-structure question (it's also LeetCode 1472), and its entire difficulty is one rule everybody knows as a user and forgets as an implementer: visiting a new page while you're back in history destroys the forward stack. Go back three pages, click a link — the three "forward" pages are gone forever. Model that correctly and the rest is bookkeeping.

The model: an array plus an index — not two stacks (though two stacks also work — see follow-ups). The index is "where you are"; entries after the index are your forward history.

Implementation

class BrowserHistory {
  #entries;
  #index;
 
  constructor(homepage) {
    this.#entries = [homepage];
    this.#index = 0;
  }
 
  visit(url) {
    // THE rule: a new visit truncates everything after the current position…
    this.#entries.length = this.#index + 1; // (truncate in place)
    // …then appends and moves onto the new entry
    this.#entries.push(url);
    this.#index += 1;
  }
 
  back(steps = 1) {
    // clamp, don't underflow — mirroring the real back button's behavior
    this.#index = Math.max(0, this.#index - steps);
    return this.#entries[this.#index];
  }
 
  forward(steps = 1) {
    this.#index = Math.min(this.#entries.length - 1, this.#index + steps);
    return this.#entries[this.#index];
  }
 
  current() {
    return this.#entries[this.#index];
  }
 
  get canGoBack()    { return this.#index > 0; }
  get canGoForward() { return this.#index < this.#entries.length - 1; }
}

Dry run — the truncation moment

const h = new BrowserHistory("home");
h.visit("a"); h.visit("b"); h.visit("c");
// entries: [home, a, b, c]   index: 3
 
h.back(2);        // "a"      entries unchanged, index: 1
h.forward(1);     // "b"      index: 2 — forward history still intact
 
h.visit("x");     // ← the moment that matters
// entries: [home, a, b, x]   index: 3 — "c" is GONE
 
h.forward(5);     // "x" — nothing to go forward to; clamped
h.back(99);       // "home" — clamped at the start, no crash

If an interviewer gives you exactly one test, it's that visit-after-back sequence. The two clamp behaviors (back(99), forward(5)) are the second test — real browsers clamp silently, and so should you.

Design decisions worth narrating

  • Array + index over two stacks — O(1) navigation by any step count (index arithmetic vs popping one-at-a-time between stacks), canGoBack/canGoForward are trivial comparisons, and the state is inspectable in one glance. Two stacks (back-stack, forward-stack) also pass — the migration between them on each step is the cost.
  • entries.length = index + 1 — truncating an array by assigning length is idiomatic and in-place; slice also works but allocates. Either is fine; forgetting truncation entirely is the fail.
  • Clamping over throwing — matches the artifact being modeled: the real back button at the start of history is disabled, not an exception. Contract fidelity beats defensive throwing here.

How the real History API differs

The class is the mental model; window.history is the same model with the browser holding the state — and three differences interviewers probe:

  1. You can't read the stack. Real history exposes length and the current entry's state — never the URLs of other entries (privacy: a page could sniff where you've been). Your class is the debuggable version of a deliberately opaque API.
  2. pushState(state, "", url) is visit without navigation — it rewrites the URL bar and pushes an entry with no page load; that single capability is what makes SPAs possible. replaceState is visit that overwrites the current entry instead of pushing (redirects, normalizing URLs — worth knowing it exists).
  3. popstate fires only for browser-initiated moves (back/forward buttons, history.back()) — not for your own pushState. So a router = your class's model + pushState on link clicks + a popstate listener to catch the user's button presses. Miss the listener and the back button breaks — the #1 hand-rolled-router bug.
// The 15-line SPA router this question is secretly about:
function navigate(url) {
  history.pushState({}, "", url);
  render(url);                       // pushState does NOT fire popstate
}
window.addEventListener("popstate", () => render(location.pathname));

A common pitfall in "sync a custom class to window.history" hybrids: calling history.back() and decrementing your own index, then also handling popstate — the index moves twice. Keep one source of truth: either the class owns the state (pure model) or the browser does (read it in popstate); mirrored state with two writers is the bug factory.

Edge cases interviewers probe

  • visit after back — the truncation rule; the question's core.
  • Over-stepping in either direction — clamp, return the landed-on entry.
  • back then back to index 0, then forward all the way — round-trip integrity; a quick invariant test (0 <= index < entries.length always).
  • Duplicate consecutive visitsvisit("a"); visit("a") creates two entries — correct! The real browser does the same (that's why you sometimes click back twice). Deduplicating is a router-level policy (replaceState), not a history-level one.
  • What's stored per entry — the interview version stores URLs; the real one stores { url, state, scrollPosition } — which is how browsers restore your scroll on back. Extending the entry shape is the natural "productionize" follow-up.

Common mistakes

  • No truncation on visit — forward history survives a new navigation; the model is simply wrong.
  • Throwing or returning undefined on over-stepped back/forward instead of clamping.
  • Two-stack versions that forget to move the current page between stacks (off-by-one where back() returns the page you're already on).
  • Hybrid class/window.history implementations with double-updating indexes (one state, two writers).
  • Believing pushState triggers popstate.

Follow-up questions

  • "Do it with two stacks." — back-stack + forward-stack, current held separately; visit clears the forward-stack (same rule, different clothes). Good test that you understand the semantics, not one encoding.
  • "Add a maxSize cap." — evict from the front when over capacity (oldest history goes first, index shifts down) — a bounded buffer; browsers really do cap (50 entries in some engines).
  • "Where does state in pushState live and what survives refresh?" — serialized by the browser (structured-clone rules — the same algorithm), survives refresh and back/forward; great trivia bridging two catalog questions.
  • "How do React Router / Next.js relate?" — thin wrappers over exactly this: pushState + popstate + a render callback, plus route matching on top. The event-emitter pattern is how they broadcast location changes to subscribers.
  • "Hash routing vs history routing?"#/path needs no server config (hashchange event, fragment never sent to the server) vs clean URLs needing a server fallback to index.html; one sentence each and the trade-off is complete.

  • Turn real DOM into a plain-object tree — the first half of understanding how React represents UI.

  • Event Delegation

    intermediate

    Implement delegate(root, selector, handler) with closest() — one listener for a thousand rows, and why frameworks did exactly this.

  • Event Emitter

    intermediate

    Implement on, off, once, and emit — the pub/sub pattern behind Node streams and countless libraries.