The History API
Date: 2026-08-17
Changing the URL without a page load. It’s what makes client-side routing possible — and it fires no event when you call it, which is why analytics, session replay and A/B testing tools all need a shim to notice that the page changed at all.
The History API is the browser interface — history.pushState, replaceState and the popstate event — for changing the URL and the session history stack without navigating.
The methods
// add a new entry — back button returns to the previous URL
history.pushState({ view: 'product', id: 1234 }, '', '/product/1234');
// change the current entry — back button skips past this state
history.replaceState({ filters }, '', '/category/coats?colour=navy');
history.back(); history.forward(); history.go(-2);pushState vs replaceState is a user-experience decision, not a technical one:
PUSH — creates a back-button step REPLACE — no new step
navigating to a product applying a filter
opening a modal you'd want back updating a sort order
to close correcting a URL after a redirect
a checkout step
← push here and the back button
← the user expects back to undo this becomes useless: fifteen presses
to escape a filter panel
Filters and sorts are the case people get wrong. Pushing an entry per filter change means the back button walks through every intermediate state. replaceState keeps the URL shareable without polluting history.
The event that doesn’t fire
Neither pushState nor replaceState fires any event. popstate fires only when the user navigates — back, forward, or a hash change — not when your code calls the API.
user clicks back → popstate fires ✓
router calls pushState → nothing fires ✗
This is the single most consequential fact in the note, because a large amount of third-party tooling assumes a page view means a document load.
// the standard shim: wrap the methods and emit your own event
['pushState', 'replaceState'].forEach((name) => {
const original = history[name];
history[name] = function (...args) {
const result = original.apply(this, args);
window.dispatchEvent(new Event('locationchange'));
return result;
};
});
window.addEventListener('popstate',
() => window.dispatchEvent(new Event('locationchange')));What breaks without it:
- Analytics page views. One page view recorded for a forty-navigation session — every funnel, path and page report is wrong — Page Views vs Events, Sessionisation
- A/B test evaluation. A client-side test that runs on document load never re-evaluates, so the variant doesn’t apply to any subsequent view — Client-Side vs Server-Side Testing
- Tag manager triggers, most of which key off page load by default — Tag Managers
- Session replay segmenting a session into pages
- Consent banners that check on load and never again — Consent Management
Most vendors offer a single-page-application mode. Turn it on deliberately and verify it, rather than assuming it — Instrumentation Debugging.
Scroll and focus, which the browser stops managing
A real navigation resets scroll and moves focus to the top of the document. pushState does neither, so client-side routing must do both by hand.
history.scrollRestoration = 'manual'; // stop the browser guessing
function onRouteChange() {
window.scrollTo(0, 0);
document.querySelector('h1')?.focus(); // needs tabindex="-1"
document.title = newTitle; // announced by screen readers
}Focus is the accessibility half and it’s routinely skipped. Without moving focus, a keyboard user’s position stays wherever the link was, and a screen reader announces nothing — so the user has no idea the page changed. Moving focus to the new page’s heading is the minimum — Focus Management, Screen Readers.
document.title must be updated too, or every entry in the browser’s history and every open tab shows the same title.
The state object
history.pushState({ scrollY: window.scrollY, filters }, '', url);
window.addEventListener('popstate', (e) => {
restore(e.state); // may be null — the initial entry has none
});- It’s serialised, with a size limit (a few hundred kilobytes, varying by browser). Don’t put a product catalogue in it
- It survives a page reload, so it must be restorable from a cold start
e.statecan be null. The first entry in the session has no state, and code assuming an object will throw on the first back press
The URL should carry anything shareable. State that only exists in the state object is lost the moment someone copies the URL — which for a filtered category page is exactly the thing customers do.
Practical
- Put filters and sorts in the query string, so the page is shareable and indexable. Then decide what a crawler should see — Faceted Navigation and Crawl Budget, URL Structure
- The server must handle every client-side route. Deep-linking to
/product/1234means the server returns something for it, not a 404 — Routing and Resolution replaceStateon a redirect so the back button doesn’t return to a URL that just redirects again — an infinite-feeling loop — Redirects and Link Equity- Test the back button on every flow, particularly checkout. Back from payment to basket is a journey customers make constantly and it breaks quietly
- The Navigation API is the modern successor, with real events and interception rather than a shim. [CHECK: support is uneven — verify before relying on it]
Where it interacts
- Rendering Strategies — client-side routing is what creates the need for all of this
- Page Views vs Events — the analytics consequence, and the most common measurement bug in single-page commerce
- Focus Management — the accessibility obligation a real navigation used to discharge for you
- Memory and Long Sessions — a session that never unloads is the other consequence of the same architecture