skip to content

In a vanilla JavaScript router, calling history.pushState({}, '', '/about') updates the address bar but the view never changes and the window's popstate listener never runs. Why does the browser not notify you, and what must the router do instead?

level: middleimportance: must knowfreq 78%

answer

  1. silent by design, no event
  2. you pushed it, you already know
  3. popstate is for traversal only
  4. render after push, plus a click handler
  5. links still navigate unless intercepted

basics

~20 s

history.pushState is deliberately silent: it changes the URL and adds a session-history entry without navigating, without a network request, and without firing popstate or hashchange. Only user traversal fires popstate, so the router must render itself after pushing.

solid answer

~40 s

`pushState` and `replaceState` only mutate the session history entry list and the displayed URL. There is no navigation, no request, no unload/load, no scroll to top, and crucially no `popstate` or `hashchange` event. The reasoning is that the code calling `pushState` already knows a route change happened, so the platform does not tell it something it just did. `popstate` exists for the case the script cannot see coming: the user pressing Back or Forward, or a script calling `history.back()`, `forward()` or `go()`. So a client router is built from three pieces: a `navigate()` function that calls `pushState` **and then** renders; a `popstate` listener that re-renders from `location`; and a document-level click handler that intercepts same-origin anchors, calls `preventDefault()`, and funnels them into `navigate()`. Miss the third piece and links do full page loads.

code

javascript · 26 lines
javascript
const app = document.querySelector('#app');
const routes = {
  '/': () => 'Home',
  '/about': () => 'About',
};

function render() {
  app.textContent = (routes[location.pathname] ?? (() => 'Not found'))();
}

function navigate(url) {
  history.pushState(null, '', url); // fires nothing
  render();                         // so render explicitly
}

window.addEventListener('popstate', render); // Back / Forward only

document.addEventListener('click', (e) => {
  const a = e.target.closest('a');
  if (!a || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
  if (a.origin !== location.origin || a.hasAttribute('download')) return;
  e.preventDefault();
  navigate(a.pathname + a.search);
});

render();

go deeper

for a junior

Recall that pushState only changes the URL and history entry — the screen is your job. Know that popstate is the Back/Forward event and that it is dispatched on window.

for a middle

Explain the push-versus-traverse split, name what pushState skips (request, load events, scroll reset, popstate, hashchange), and sketch the three-part router: navigate, popstate listener, delegated link interception.

for a senior

Diagnose the real failures — double renders, Back moving the URL but not the view, anchors escaping the router — and defend the click-interception guards for modifier keys, target, download, and cross-origin links.

for a principal

Take a position on whether a hand-rolled router is worth its edge cases at all, and on the invariant that the view must always be derivable from location plus history.state rather than from mutable module state.

## What pushState actually does `history.pushState(state, unused, url)` performs exactly three things: it serializes `state` and stores it on a **new** session history entry, sets that entry's URL to `url`, and makes it the current entry. It does not fetch anything, does not replace the document, does not run `beforeunload`, does not reset scroll position, and does not fire an event. A few mechanical details are worth carrying into an interview. The second argument is a title that every browser ignores; the convention is to pass `''`. The `url` argument is resolved against the current document's URL, so `pushState({}, '', 'about')` from `/docs/` yields `/docs/about`. It must be **same-origin**, and a cross-origin value throws a `SecurityError`. Passing no URL (or the same one) keeps the address bar unchanged while still creating an entry — occasionally useful for capturing a state snapshot. ## Why popstate does not fire The HTML specification fires `popstate` when the browser **traverses** session history to a different entry within the same document. Traversal means the Back or Forward button, a keyboard or trackpad back gesture, or `history.back()` / `history.forward()` / `history.go(n)`. It does not mean "the current entry changed by script". The design is intentional. If `pushState` fired `popstate`, every router would immediately re-enter its own handler and would have to distinguish self-inflicted events from user-driven ones. Instead the split is clean: **you push, so you render; the user traverses, so the browser tells you.** `hashchange` is the same story from the other direction. It fires when the document's fragment changes through a navigation — clicking `<a href="#section">`, assigning `location.hash` — and it too is not fired by `pushState`, even when the pushed URL differs only in its fragment. ## The three pieces of a client router ```js const routes = { '/': Home, '/about': About }; function render() { const view = routes[location.pathname] ?? NotFound; document.querySelector('#app').replaceChildren(view()); } function navigate(url) { history.pushState(null, '', url); render(); // pushState will not do this for you } window.addEventListener('popstate', render); // Back / Forward render(); // first paint ``` The missing third piece is link interception. A plain `<a href="/about">` still performs a full cross-document navigation, which throws the whole SPA away and reloads it. Routers attach one delegated click listener on `document` and call `preventDefault()` only when the click is safe to hijack: ```js document.addEventListener('click', (e) => { const a = e.target.closest('a'); if (!a || e.defaultPrevented) return; if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; // new tab / window if (a.target && a.target !== '_self') return; if (a.hasAttribute('download') || a.origin !== location.origin) return; e.preventDefault(); navigate(a.pathname + a.search + a.hash); }); ``` Every one of those guards exists because a user did something reasonable that the naive version broke: cmd-click to open in a new tab, a download link, an external link, a link with `target="_blank"`. ## Symptoms in the wild - **URL changes, screen does not.** The `navigate()` helper pushes but forgets to render — or renders only in the `popstate` handler, which never fires for pushes. - **Back changes the URL but not the view.** The `popstate` listener is missing, or it was attached to `document` instead of `window`; `popstate` is fired at the `window`. - **Clicking a link reloads the whole app.** No click interception, so the anchor's default cross-document navigation ran. - **Rendering runs twice per click.** The handler both listens to `popstate` and re-renders inside `navigate()` *and* something calls `history.back()` internally. Trace which of the two paths fired. ## Reading the current state Because no event announces a push, the current entry's data is read synchronously from `history.state`, and the target entry's data arrives on the traversal event as `event.state`. Keeping the two in sync — always render from `location` plus `history.state`, never from a variable you mutated on the side — is what keeps Back, Forward and reload consistent.

  • Why did the specification choose not to fire popstate on pushState?
    Because the caller already knows. The script that pushed the entry can render synchronously on the next line; firing an event would make routers re-enter their own handler and force them to distinguish self-triggered events from real user traversal. The event is reserved for the case the script cannot observe otherwise: Back, Forward, or history.go().
  • Your router renders on popstate and after every push, yet plain anchor clicks still reload the whole app. What is missing?
    Link interception. An `<a href>` performs a cross-document navigation by default; the History API never sees it. Add a delegated click listener that calls preventDefault and routes internally — but only for primary-button clicks with no modifier keys, no target, no download attribute, and a same-origin href.
  • What are the pushState arguments, and which one do browsers ignore?
    `pushState(state, unused, url)`. The state is structured-cloned onto the new entry; the second argument is a title that no browser applies, so pass an empty string; the url is resolved relative to the current document and must be same-origin or the call throws a SecurityError. Omitting the url keeps the current one.

saying these in an interview costs you the question

  • Expects popstate to fire after every pushState call
  • Attaches the popstate listener to document instead of window
  • Assumes pushState makes the browser fetch the new URL
  • Intercepts every click, breaking cmd-click and downloads
  • Thinks the second pushState argument sets the document title

context