How does the window.history API work?
The Short Answer
The window.history API lets you manipulate the browser's session history — the stack of pages the user has visited in the current tab. pushState() adds a new entry without a page reload, replaceState() modifies the current entry, and popstate fires when the user navigates with back/forward buttons. This is the foundation of client-side routing in SPAs (React Router, Next.js, Vue Router) — it lets you change the URL and update the page without a full reload while keeping the back button working correctly.
Core Methods
| Method | What It Does | Triggers Page Load? |
|---|---|---|
| history.pushState(state, '', url) | Adds new entry to history stack | No |
| history.replaceState(state, '', url) | Replaces current entry (no new entry) | No |
| history.back() | Same as clicking browser back button | Depends (popstate for SPA) |
| history.forward() | Same as clicking browser forward button | Depends (popstate for SPA) |
| history.go(n) | Go n steps (negative = back, positive = forward) | Depends |
| history.length | Number of entries in the session history | N/A (property) |
pushState and replaceState
pushState is the key method — it changes the URL in the address bar and adds a new history entry without triggering a page navigation. The browser doesn't send a request to the server; your JavaScript handles the 'navigation' by updating the DOM. replaceState does the same but overwrites the current entry instead of adding a new one — useful for redirects or updating state without cluttering the back button history.
// pushState(stateObject, unused, url)
// - stateObject: data associated with this history entry (retrieved on popstate)
// - unused: historically 'title' — pass empty string
// - url: the new URL to display (must be same origin)
// Navigate to a new 'page' without reload
history.pushState({ page: 'products', id: 42 }, '', '/products/42');
// URL bar now shows /products/42
// No HTTP request — page doesn't reload
// Back button now has an entry to go back to
// Replace current entry (no new back-button entry)
history.replaceState({ page: 'products', id: 42, tab: 'reviews' }, '', '/products/42?tab=reviews');
// URL updates but back button still goes to the PREVIOUS page
// Useful for: filters, sort order, tab changes
// State object is cloned (structured clone algorithm)
// Can contain: objects, arrays, numbers, strings, Dates, Maps, Sets
// Cannot contain: functions, DOM elements, class instances
// Read current state
console.log(history.state); // { page: 'products', id: 42, tab: 'reviews' }
// URL restrictions: must be same origin
// ✅ history.pushState({}, '', '/new-path')
// ✅ history.pushState({}, '', '?query=value')
// ✅ history.pushState({}, '', '#section')
// ❌ history.pushState({}, '', 'https://other-domain.com') // SecurityError
The popstate Event
popstate fires when the user navigates through history (back/forward buttons or history.back()/history.forward()). It does NOT fire when you call pushState or replaceState. The event's state property contains the state object you passed to pushState/replaceState for that entry. This is how SPA routers know which page to render when the user clicks back.
// Listen for back/forward navigation
window.addEventListener('popstate', (event) => {
// event.state is the state object from pushState/replaceState
console.log('Navigated to:', event.state);
if (event.state?.page === 'products') {
renderProductPage(event.state.id);
} else if (event.state?.page === 'home') {
renderHomePage();
} else {
// null state — initial page load entry
renderHomePage();
}
});
// Simulate navigation
history.pushState({ page: 'home' }, '', '/');
history.pushState({ page: 'products', id: 1 }, '', '/products/1');
history.pushState({ page: 'products', id: 2 }, '', '/products/2');
// User clicks back button:
// popstate fires with state: { page: 'products', id: 1 }
// URL changes to /products/1
// User clicks back again:
// popstate fires with state: { page: 'home' }
// URL changes to /
// ⚠️ popstate does NOT fire on pushState/replaceState
// You must update the UI yourself when calling those methods
Building a Simple Router
Here's a minimal client-side router that demonstrates how SPA frameworks use the History API. It intercepts link clicks, uses pushState to update the URL, and renders the appropriate content. The popstate listener handles back/forward navigation. This is essentially what React Router and similar libraries do under the hood (with much more sophistication).
type Route = {
path: string;
render: () => string;
};
const routes: Route[] = [
{ path: '/', render: () => '<h1>Home</h1>' },
{ path: '/about', render: () => '<h1>About</h1>' },
{ path: '/contact', render: () => '<h1>Contact</h1>' },
];
function navigate(path: string) {
history.pushState({ path }, '', path);
renderRoute(path);
}
function renderRoute(path: string) {
const route = routes.find((r) => r.path === path);
const app = document.getElementById('app')!;
app.innerHTML = route ? route.render() : '<h1>404</h1>';
}
// Handle back/forward
window.addEventListener('popstate', (event) => {
const path = event.state?.path ?? window.location.pathname;
renderRoute(path);
});
// Intercept link clicks (prevent full page reload)
document.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
const anchor = target.closest('a');
if (anchor && anchor.origin === window.location.origin) {
event.preventDefault();
navigate(anchor.pathname);
}
});
// Initial render
renderRoute(window.location.pathname);
Common Patterns
Use pushState for:
- ✅Page navigations that should be in the back-button history
- ✅Route changes in SPAs (React Router uses this internally)
- ✅Pagination — /products?page=2 should be a new history entry
- ✅Opening detail views — /products/42 from a list
Use replaceState for:
- ✅Filter/sort changes — don't pollute back history with every filter toggle
- ✅Redirects — replace the redirect URL with the final destination
- ✅Updating query params without adding history entries
- ✅Storing scroll position or form state on the current entry
Why Interviewers Ask This
This question tests understanding of how SPAs work under the hood. Interviewers want to see that you know how client-side routing works without page reloads (pushState + popstate), understand the difference between pushState and replaceState (new entry vs replace), know that popstate only fires on back/forward navigation (not on pushState calls), can explain the same-origin restriction, and understand how frameworks like React Router build on this API. It reveals whether you understand the browser platform or just use framework abstractions without knowing what they do.
Quick Revision Cheat Sheet
pushState: Add history entry + change URL — no page reload, back button works
replaceState: Replace current entry — URL changes but no new back-button entry
popstate event: Fires on back/forward — NOT on pushState/replaceState
history.state: Read the state object of the current history entry
Same-origin only: Can only pushState to URLs on the same origin (security)
SPA routing: pushState + popstate + intercepted clicks = client-side router