The History API and Client-Side Routing
The History API lets JavaScript change the URL and manage browser history without a page reload, which is what makes client-side routing possible.
The History API is the browser interface that lets JavaScript change the URL shown in the address bar and add or modify entries in the browser’s back/forward history, all without triggering a full page reload. It’s the piece of platform machinery that makes client-side routing possible: every JavaScript framework’s router — React Router, Vue Router, or a hand-rolled one — is built on top of these same few browser methods.
The core methods
Three methods do almost all of the work:
history.pushState(state, unused, url)— adds a new entry to the browser’s history stack and updates the address bar tourl, without reloading the page. Thestateargument is arbitrary data associated with that history entry, retrievable later.history.replaceState(state, unused, url)— the same idea, but it replaces the current history entry instead of adding a new one. No new back-button stop is created.history.back(),history.forward(),history.go(n)— programmatically navigate the history stack, equivalent to the browser’s own back/forward buttons.
The middle unused parameter is a historical artifact — early specs intended it as a page title, no browser ever implemented that, and it’s simply ignored today. Most code passes an empty string.
// Navigate to /profile without a page reload
history.pushState({ page: "profile" }, "", "/profile");
Calling pushState only changes the URL and history stack. It does not fetch anything, render anything, or otherwise change what’s on screen — that part is entirely the router’s job, which is why a bare pushState call by itself does nothing visible until application code responds to it.
The popstate event
Changing the URL is only half of what a router needs. The other half is reacting when the user clicks the browser’s back or forward button, which is where the popstate event comes in — it fires on window whenever the active history entry changes due to user navigation (back/forward, or a programmatic history.go()), carrying the state object that was attached when that entry was created.
window.addEventListener("popstate", (event) => {
renderPageFor(location.pathname, event.state);
});
Critically, popstate does not fire when your own code calls pushState or replaceState — only in response to actual history navigation. A router has to call its own render function immediately after every pushState call, and separately listen for popstate to handle back/forward navigation; forgetting the first half is a common bug that leaves the URL updated but the page content unchanged until the user happens to navigate away and back.
Why this replaced hash-based routing
Before the History API existed, client-side routers commonly used the URL fragment — everything after a # — to encode route state, since changing location.hash doesn’t trigger a page reload and was, at the time, the only way to get a shareable, bookmarkable URL that JavaScript could read and change freely. That approach still works and still shows up in specific cases (some static-hosting setups without server-side routing support lean on it deliberately), but it has real downsides: hash fragments were historically excluded from being sent to the server at all, which broke straightforward server-side rendering and made analytics harder, and a URL like example.com/#/profile reads as noticeably less clean than example.com/profile.
The History API’s pushState produces real, clean paths that look identical to URLs a traditional multi-page site would use, while still avoiding a full page reload. The trade-off is that it requires server cooperation: because /profile is now a real path the browser might request directly (a fresh page load, a bookmark, a shared link), the server has to be configured to serve the same single-page application shell for that path instead of returning a 404 — a detail that’s easy to overlook until someone refreshes the page on a client-routed URL and gets an error.
How this fits with modern frameworks
Frameworks that do server-side rendering or static generation still rely on the History API for the same purpose once a page has loaded and hydrated in the browser — the initial page comes from the server, but subsequent in-app navigation switches to client-side pushState calls to avoid full reloads on every click. This is also the layer beneath the browser’s View Transitions API, which can animate between two states of a page during exactly this kind of client-side navigation, and beneath the Speculation Rules API, which can prefetch or prerender pages a user is likely to navigate to next.
The takeaway
The History API is a small set of primitives — pushState, replaceState, and the popstate event — that together let JavaScript rewrite the address bar and manage back/forward navigation without a page reload. Every client-side router is a layer of application logic built on top of exactly these methods, and understanding them directly makes it much easier to debug the routing bugs — a stale back button, a refresh that 404s — that inevitably surface once a framework’s abstraction leaks.
Tagged
Keep reading
Takina · · 4 min read Object.groupBy() in JavaScript, Explained
Object.groupBy() buckets array items by a key you compute, replacing the reduce() boilerplate developers have written for years. How it works.
Takina · · 4 min read Web Locks API: Coordinating Work Across Browser Tabs
The Web Locks API lets JavaScript acquire named locks shared across tabs, so only one tab does work like a token refresh or a write at a time.
Takina · · 4 min read Svelte 5 Runes Explained
Svelte 5 runes like $state and $derived replace the old reactive-assignment magic with explicit function calls that work anywhere in a file.