JavaScript History API Table
| Piece | What it does | Field note |
|---|---|---|
pushState(state, '', url) | New history entry, no reload | The URL changes and the page does NOT reload - SPA navigation's engine |
replaceState | Rewrite current entry | No new entry - redirects, filters, and debounced URL sync use this |
popstate event | Back/forward happened | Fires on navigation, NOT on your own pushState - listen, then re-render |
history.state | The stored blob | Serializable state per entry - survives reloads attached to the URL |
history.back / forward / go | Programmatic travel | go(-2) jumps two entries; cross-origin entries are opaque |
scrollRestoration | Auto or manual | 'manual' stops the browser restoring scroll - virtual lists re-scroll themselves |
hashchange vs popstate | #fragment vs full entry | Hash changes fire hashchange AND add entries - the routing precursor |
404 on refresh | The server half you forgot | pushState URLs need server rewrites to your index - SPA hosting 101 |
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
- Navigate without reload: history.pushState({ view: 'settings' }, '', '/settings') - then render; the state blob travels with the entry and comes back on popstate.
- Sync filters without history spam: use replaceState for debounced URL updates (search boxes, tab state) - rewrite the entry instead of stacking twenty.
- 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.