RoutingGENERALSPEC-EVOLVINGBROWSER-SPECIFICPLATFORM-SPECIFIC

History and Navigation

The session history stack, pushState versus replaceState, popstate, and why intercepting navigation without handling back is the most common router bug there is.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

What does the history stack actually hold, and why is the back button the hardest part of a client router to get right?

The user intent

A person is three screens deep, realises they took a wrong turn, and presses back. They expect the previous screen — the actual one, with its scroll position and its state — not a guess at it.

The obvious build

Call history.pushState when navigating, and the browser handles the rest. Back and forward are the browser's job; that is why they are on the browser's toolbar.

Why it breaks

Back changes the URL in the address bar and nothing on screen changes, because pushState writes an entry and no code is listening for the traversal that reads it. The user is now on a page whose URL disagrees with its content.

How it breaks in a real browser
  • Back changes the URL in the address bar and nothing on screen changes, because pushState writes an entry and no code is listening for the traversal that reads it. The user is now on a page whose URL disagrees with its content.
  • Back leaves the application entirely, because the modal that was opened, the tab that was switched and the filter that was applied never created entries — so the first press unwinds past all three.
  • On Android, where back is a system gesture used constantly, a modal without a history entry means back closes the site instead of the dialog (The Viewport and Device Pixels).
  • After signing in, back returns to the login page, which redirects forward again. The user is trapped in a two-entry loop and the only way out is a long-press on back (Login Redirects and the Open-Redirect Trap).
  • beforeunload was wired up to warn about an unsaved draft and never fires, because a client-side route change does not unload the document. The draft is gone with no warning.
  • Forward stops working after a "guard" that pushes an entry back onto the stack, because pushing truncates every forward entry the user had.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • Each tab holds a session history: an ordered list of entries plus a pointer to the current one. An entry carries a URL, a state object, a scroll position and the document it belongs to.
  • history.pushState(state, "", url) inserts an entry after the current one, moves the pointer to it, and truncates every forward entry. It issues no request and fires no event — the caller already knows it happened.
  • history.replaceState(state, "", url) overwrites the current entry in place. The pointer does not move, nothing is truncated, and back still goes wherever it went before.
  • popstate fires when the active entry changes by traversal — back, forward, history.go(), or a gesture. It does not fire for pushState or replaceState. This asymmetry is the origin of the bug: writing works without listening, so nobody notices the listener is missing until someone presses back.
  • The traversal has already happened by the time popstate fires. There is nothing to cancel; you are being told where you now are, not asked whether it is allowed.
  • The state object is structured-cloned and stored with the entry, so it survives reload and traversal. It has a user-agent-defined size limit, and it is the right place for a scroll offset or a list cursor — not for a page of data (Structured Clone and Transferables).
  • Fragment-only changes fire hashchange as well, and a same-document fragment navigation still creates an entry. That is why hash-based routers work at all.
  • A page put into the back/forward cache is frozen rather than destroyed: script state, timers and the DOM survive, and returning to it fires pageshow with persisted set rather than re-running the page. A tab restored this way did not re-execute your router (Long-Lived Clients and Version Skew).

What this makes the browser do

And which of it is avoidable.

  • pushState and replaceState are cheap — an entry write and a serialisation of the state object. Nothing renders, nothing is fetched, and nothing is validated.
  • The retained cost is the stack: every entry holds a URL, a state object and a scroll position for the lifetime of the tab. A router that pushes per keystroke is a memory profile as well as a usability problem.
  • Serialising a large state object on every navigation is real main-thread work, and it is synchronous. Structured clone of a deep object on a slow device is a long task in an unexpected place (Long Tasks).
  • Avoidable: pushing entries the user never asked for. Canonical corrections, redirects and continuous control writes should all replace.

The stack, and what each operation does to it

Nearly every history bug becomes obvious once you can picture the list and the pointer. There is one list per tab. pushState inserts after the pointer and discards everything beyond it. replaceState overwrites in place. Traversal moves the pointer without changing the list at all — which is why popstate is a notification rather than a request.

The truncation is the part that surprises people. Forward history is not a separate thing that survives; it is the tail of the same list, and any push destroys it. That is what makes the "push an entry to cancel a back press" guard so damaging: it does not restore anything, it deletes the user's forward path.

  • The asymmetry is the whole lesson: writing is silent, traversal is an event. Code that only writes appears to work perfectly.
  • The state object rides with the entry and survives a reload, which makes it the natural home for a scroll offset (Scroll Restoration).
  • popstate tells you the pointer moved. It does not tell you which direction, and there is no portable way to ask.
ONE TAB, ONE LIST, ONE POINTER

  start                              [1] /            <- current

  click /orders      pushState       [1] /
                                     [2] /orders      <- current

  click /orders/7    pushState       [1] /
                                     [2] /orders
                                     [3] /orders/7    <- current
                                         state: { scrollY: 812 }

  press Back         (traversal)     [1] /
                                     [2] /orders      <- current
                                     [3] /orders/7        still here; Forward returns to it

                                     popstate fires. history.state is now [2]'s state.
                                     The list did not change. The pointer did.

  click /orders/9    pushState       [1] /
                                     [2] /orders
                                     [3] /orders/9    <- current

                                     *** the old [3] /orders/7 is gone ***
                                     push truncates every forward entry

  open the items     replaceState    [3] /orders/9?tab=items   <- current
  tab                                no new entry; Back still goes to [2] /orders


WHAT FIRES WHAT

  pushState / replaceState  ->  nothing. No event, no request, no render.
  Back / Forward / go()     ->  popstate  (same document)
  fragment change           ->  hashchange, and popstate for a traversal
  real navigation away      ->  pagehide, and beforeunload if the document unloads
  restore from bfcache      ->  pageshow with persisted === true, no re-execution

Push, replace, or neither

FRAMEWORK-SPECIFICRouters expose this as an option with different names and different defaults — a replace flag on a navigate call, a replace prop on a link component, or a distinct method. Some default to push for every programmatic navigation, which makes redirect loops the default behaviour unless you opt out.

Every URL change is one of three decisions, and the criterion is not technical. Ask what one press of back should do from the resulting screen, and the answer names the operation.

The case people get wrong most often is the redirect. A canonical correction, a post-login return and a "you do not have access, here is the dashboard" bounce are all navigations the user did not perform, and none of them belongs in their history. Push one and back becomes a trap.

This state just changed. What happens to the history stack?

From the screen this produces, what should one press of Back do?

`pushState` — a new entry

when Back should return to the previous state: opening a record, changing page, switching a major tab, opening a modal on a touch platform.

cost Every push is an entry the user must traverse, and it truncates their forward history. Push per keystroke and the back button is gone.

`replaceState` — overwrite in place

when The user did not ask for this URL, or asked for it continuously: canonical corrections, redirects, typing in a search box, dragging a slider, saving a scroll offset.

cost The previous URL is unrecoverable. Replace a state the user would have wanted back and you have removed their only undo.

No URL change at all

when The state is transient, private or per-device: a hover, an unsent draft, a dismissed tooltip, a focus ring.

cost It does not survive a reload and cannot be shared. If a user would ever send this view to a colleague, this is the wrong answer (The URL Is Application State).

A real navigation (`location.assign`)

when The document itself must be replaced: after logout, after a deployment the running tab cannot adopt, or when leaving for another origin.

cost Full document teardown and reload — everything in memory is discarded, which is sometimes exactly the point (Deploying a Frontend).

The most common router bug, and its cousins

The headline failure is small enough to state in a sentence: the router intercepts clicks and writes history entries, but nothing listens for the traversal that reads them back. It survives every review because the forward path works perfectly, and the reverse path is the one nobody exercises.

The rows below are ordered roughly by how often they reach production. Note that three of them are failures of a *fix* rather than of the original code — the sentinel guard, the modal entry and the canonicalising push are all attempts to solve a real problem that create a worse one.

Back-button failures, most common first
TriggerSymptomCauseResponse
Router calls pushState but never handles popstateBack changes the address bar; the screen does not changepushState fires no event, so writing works without listening. The traversal path was never builtHandle popstate before you add pushState, and re-run the full match-and-render path on it — including focus, announcement and scroll.
Modal, drawer or filter opened without an entryBack leaves the site instead of closing the dialog, especially on AndroidThe UI state was not modelled as navigation, but the user treats back as a universal dismissPush an entry for dismissible overlays on touch platforms and close them on popstate; accept the extra desktop entry (Accessible Component Patterns).
Post-login redirect pushed instead of replacedBack returns to the login screen, which immediately redirects forward againThe login entry is still in the stack, and the redirect added anotherReplace on every redirect. A navigation the user did not perform never belongs in their history (Login Redirects and the Open-Redirect Trap).
A guard pushes an entry to "cancel" a back pressForward stops working entirely; a fast double-back escapes the guard anywayThe traversal already happened, and the compensating push truncated all forward entriesDo not block traversals on the history API. Autosave the draft, or use the Navigation API's cancellable event where it is available (Form State Is a Draft).
beforeunload used to warn about unsaved workThe warning never appears on a route change; the draft is lost silentlyA client-side navigation does not unload the document, so the event has nothing to fire onGuard route changes in the router. Keep beforeunload for actual document unload, and expect it to also disqualify the page from bfcache.
A route removed in a deploy, an old entry still pointing at itBack throws, or renders a blank page, in tabs opened before the releaseHistory outlives the bundle, and traversal is assumed to hit a known routeMake the not-found route reachable from traversal too, and reload the document when the running bundle cannot serve the entry (Long-Lived Clients and Version Skew).

How to build it

Most important first.

  • Treat back as a correctness requirement, in the same category as the page rendering at all. If a state change is worth a URL, its reversal has to work.
  • Wire the listener before the writer. A router should handle popstate first, then add pushState, so a broken traversal is impossible rather than merely unlikely.
  • Answer one question per state change: should back undo this? Yes means push. No means replace. That single question resolves nearly every case correctly (URL Parameters).
  • Never push an entry for a navigation the user did not perform. Redirects, canonicalisations and post-authentication returns all replace (Login Redirects and the Open-Redirect Trap).
  • Store only pointers in the state object — a scroll offset, a selected id, a cursor. Data belongs in a cache keyed by the URL, not in the history entry (The Client Cache Model).
  • For destructive traversals — leaving a form with unsaved changes — prefer a design that does not need blocking: autosave a draft, or make the state resumable, because there is no reliable way to cancel a traversal on the history API (Form State Is a Draft).
  • Where the Navigation API is available, use it as a progressive enhancement: it exposes a single cancellable navigate event covering both link clicks and traversals, and it removes most of the interception code (Client-Side Routing).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • A traversal is a navigation. Back and forward must produce the same focus move and the same announcement as a forward navigation, and this is exactly where routers stop — the popstate path frequently skips the accessibility work the click path does (Focus Management).
  • Users of switch access, voice control and screen readers rely on back heavily, because it is one reliable control that always exists. A back button that leaves the URL and the content disagreeing is worse for them than for a mouse user, who at least sees the screen did not change.
  • Modals and drawers that are dismissible with a system back gesture need a history entry to dismiss. On touch platforms, back is the primary dismiss affordance, and Escape is not available (Accessible Component Patterns).
  • Announce the destination, not the mechanism. "Orders" is useful; "navigated back" is not, and repeating it on every traversal is noise (Live Regions and Announcement).
  • Never trap a user in history. A redirect loop between two entries is unescapable for anyone who cannot long-press back to open the history menu.

What can go wrong

Failure modes
  • Writing without listening: pushState implemented, popstate not handled. The URL and the view desynchronise on the first back press.
  • History flooding from high-frequency pushes, which makes back functionally unavailable to the user.
  • The sentinel-entry guard, where a router pushes an entry back onto the stack to "cancel" a traversal. It truncates forward history, breaks the forward button, and a fast double-back escapes it anyway.
  • Depending on beforeunload for client-side route changes. It fires for document unload only, so it protects against closing the tab and against nothing else.
  • Assuming the document is fresh on pageshow. A bfcache restore returns a document that has been running for an hour, with stale data, expired sessions and timers that were paused mid-flight (Session Expiry and the Refresh Race).
  • The mitigation fails too: adding a history entry per modal fixes Android back and creates entries the desktop user did not expect, so back from a page they never opened a dialog on now feels one press short.
  • Storing a large object in the state, which either throws on the size limit or costs a synchronous clone on every navigation.
What can arrive out of order
  • A traversal and an in-flight data request for the entry being left. The response arrives after popstate and renders into the restored view unless it was aborted (Cancelling a Request Nobody Is Waiting For).
  • Rapid back-then-forward, where two traversals are processed before either render completes and the later state wins over the correct one.
  • A pushState issued from an async callback after the user has already navigated elsewhere, which silently rewrites the URL of a page it does not describe.
  • A bfcache restore racing a background revalidation: pageshow and a refresh both target a view whose data is an hour old (Stale-While-Revalidate).
Security
  • The URL in a history entry is fully attacker-suggestible: a link can push any same-origin path. pushState cannot change the origin, which is the one guarantee the API does give you (The Same-Origin Policy).
  • History entries persist in the browser profile. Anything sensitive in a URL — a token, an email, a one-time code — is now in a list the user can browse and sync across devices (Cookies vs Script-Readable Tokens).
  • A post-login redirect target must be validated before it is pushed or replaced. This is the most common open-redirect surface in a single-page application (Login Redirects and the Open-Redirect Trap).
  • The state object is not a trust boundary. It survives reload and can be edited by any script on the page, so a permission or a price stored there is a suggestion (What the Frontend Is Responsible For in Auth).
  • bfcache restores a document with its old authorisation assumptions intact. A user who signed out in another tab can return to a fully rendered authenticated view; the fix is to revalidate on pageshow, not to disable the cache (Auth Across Tabs).
Misreads
  • "popstate fires when the URL changes." It fires when the active history entry changes by traversal. Your own pushState changes the URL and fires nothing.
  • "You can cancel a back navigation." Not with the history API. By the time you hear about it, it has happened. Cancellation is what the Navigation API adds, where it is available.
  • "history.length tells me how deep the user is." It counts entries in the whole session, including ones from before your page existed, and it never decreases in the way you would expect.
  • "Back is free because the browser caches it." For a cross-document navigation, often yes — bfcache is genuinely a restore. For a client-side route change there is no cache unless you built one (The Client Cache Model).
  • "We handle back; the router does it." Verify. A router that handles popstate for its own view may still skip the focus move, the announcement and the scroll restore that a real back press performed (Scroll Restoration).

Measuring it, and what changes in the field

How you would see this
  • The cheapest test in this whole module: navigate five screens deep, then press back five times and forward five times, checking the URL, the content, the scroll position and the focus at every step.
  • Log every pushState, replaceState and popstate with the resulting URL during development. History flooding and missing traversal handling are both obvious in that log and invisible without it (Debugging State).
  • The Performance panel shows structured-clone cost on navigation if the state object has grown; it appears as script time attached to the navigation, not to rendering.
  • Field error tracking with the URL attached is how you find the redirect loops, because users escape them rather than reporting them (Frontend Error Tracking).
Slow device, slow network, large data, old tab
  • On touch platforms, back is a gesture used many times a minute, and the edge-swipe may also be a traversal. Every history decision is felt far more often than on desktop.
  • On a slow network, a traversal is expected to be instant because the browser used to serve it from cache. A client router that refetches on back makes the fastest action in the browser feel like the slowest (Stale-While-Revalidate).
  • In a tab left open for hours, the stack can hold hundreds of entries with their state objects, and a bfcache restore can return a document whose assumptions expired long ago.
  • Across a deployment, a history entry can point at a route that no longer exists in the newly loaded bundle. Traversal has to tolerate an unknown URL rather than crash (Deploying a Frontend).
What this costs
  • Modelling every UI state as a history entry gives users a reliable undo and clutters the stack; modelling none of it gives a clean stack and a back button that exits the app. Neither extreme is right, and the line is per-product.
  • The state object survives reload for free and costs a synchronous structured clone plus a size limit you cannot query portably.
  • Adopting the Navigation API removes a large amount of interception code and adds a capability check plus a fallback path, so for a while you maintain both (Polyfills vs Transpilation).
  • Autosaving a draft instead of blocking a traversal is strictly better for the user and moves a hard question — what a saved-but-unsubmitted record means — into your data model (Form State Is a Draft).

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe session history model, the pushState/replaceState/popstate semantics and the truncation of forward entries on push are specified in HTML and behave identically across Blink, Gecko and WebKit.
  • SPEC-EVOLVINGThe Navigation API adds a cancellable navigate event covering link clicks, form submissions and traversals, plus a real entry list. It has shipped in Chromium and is not universally available, so it is a progressive enhancement over the history API rather than a replacement for it.
  • BROWSER-SPECIFICBack/forward cache eligibility rules differ: an open WebSocket, an unload listener or certain cache-control headers can disqualify a page, and the disqualifying set is not the same in Chromium, Firefox and Safari. Chromium reports the reason in the Application panel; the others do not expose an equivalent list.
  • PLATFORM-SPECIFICBack is a system-level gesture on Android and a swipe or toolbar control elsewhere, so how often it is pressed — and what users expect it to dismiss — varies by platform far more than by browser.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Runtime Internals — the structured clone algorithm behind the history state object: what it can serialise, what it refuses, and why cloning a deep graph is synchronous main-thread work.
OS & Networkinghttp-request-lifecycle