JavaScript History API Table

PieceWhat it doesField note
pushState(state, '', url)New history entry, no reloadThe URL changes and the page does NOT reload - SPA navigation's engine
replaceStateRewrite current entryNo new entry - redirects, filters, and debounced URL sync use this
popstate eventBack/forward happenedFires on navigation, NOT on your own pushState - listen, then re-render
history.stateThe stored blobSerializable state per entry - survives reloads attached to the URL
history.back / forward / goProgrammatic travelgo(-2) jumps two entries; cross-origin entries are opaque
scrollRestorationAuto or manual'manual' stops the browser restoring scroll - virtual lists re-scroll themselves
hashchange vs popstate#fragment vs full entryHash changes fire hashchange AND add entries - the routing precursor
404 on refreshThe server half you forgotpushState URLs need server rewrites to your index - SPA hosting 101
Reference: the MDN History API reference. pushState is the SPA navigation engine: it adds a history entry and changes the address bar WITHOUT reloading - then YOUR code renders the new view. The two-entry contract: pushState/replaceState write entries, and popstate fires when the USER travels (back/forward) - it never fires for your own pushes, which is why routing libraries pair both. Bottom line: state blobs ride each entry and survive reloads, scrollRestoration='manual' hands scroll control to virtual lists, and the deep-link half lives on the server - a pushed URL that 404s on refresh is a missing rewrite rule. Related tools: fetch table (loading data after the URL changes), event listeners table (the popstate wiring), and URL API table (parsing and building the URLs you push).

The History API is the SPA navigation engine: pushState adds a history entry and changes the address bar WITHOUT reloading - and then your code renders the new view. The browser's back button keeps working because real entries exist; the page never reloads because nothing was navigated.

Bottom line: the API is a two-entry contract - pushState/replaceState WRITE entries, and popstate fires when the USER travels between them (never for your own pushes). Routing libraries are exactly this pair plus a URL parser: listen for popstate, read the URL, render the matching view.

The honest part: pushState has a server half people forget. The URL you pushed must return the app (not a 404) when a user refreshes it or shares it - which means every SPA host needs rewrite rules pointing unknown paths at index.html. Navigation worked in your test because you never refreshed on a deep link.

How to use

  1. Navigate without reload: history.pushState({ view: 'settings' }, '', '/settings') - then render; the state blob travels with the entry and comes back on popstate.
  2. Sync filters without history spam: use replaceState for debounced URL updates (search boxes, tab state) - rewrite the entry instead of stacking twenty.
  3. Control scroll yourself: history.scrollRestoration = 'manual' for virtualized lists - the browser's automatic scroll restore fights your own positioning.

Frequently asked questions

Why doesn't my popstate listener fire when I call pushState?

By design - popstate fires on USER navigation (back, forward, history.go), not on programmatic pushes. If it fired for pushState too, every navigation would render twice: once in your push handler and once in the listener. The architecture is therefore: pushState calls render() directly, and the popstate listener ALSO calls render() after reading location or event.state - both paths converge on one render function. Framework routers wrap exactly this: push = update URL + render; popstate = read URL + render.

What is the difference between pushState and replaceState?

Whether the back button undoes it. pushState adds a NEW entry - back returns to where you were, which is right for navigation (view changes, pagination). replaceState REWRITES the current entry - the back button skips it entirely, which is right for state that is not a new 'place': debounced search filters, tab selections, tracking-code cleanup, canonical URL corrections. The smell test: would a user expect the back button to undo this? If yes, push; if it is the same page wearing different query params, replace - otherwise every keystroke becomes a history entry the user must back through.

What lives in the state argument, and when does it beat URL params?

A serializable blob attached to that history entry: history.state reads it, and popstate's event carries it. It survives reloads (the browser persists it with the entry) but NOT sharing - state is local to that browser session, so anything a fresh visitor needs must live in the URL. The split: URL params for shareable, bookmarkable facts (page number, query, id); state for ephemeral context (scroll offsets, cached component data, a draft position) that makes back-navigation instant without refetching. Do not store functions or DOM nodes - serialization strips them silently to null.

Why does my pushed URL 404 on refresh?

Because the server never heard about it. pushState only changes the BROWSER's address bar and history - the server still only knows the routes it was configured with, so /settings pushed client-side returns 404 when requested directly by a refresh, a shared link, or a crawler. The fix is server-side rewrites: every unknown path serves index.html (SPA hosting panels call this 'rewrite all to index' or 'single-page app' mode), letting the app boot and route from the URL. The complementary discipline: hash routing (#/settings) avoids the server requirement entirely at the cost of uglier URLs - which is the tradeoff the History API was created to end.

Related tools