JavaScript History API Guide: Complete Tutorial
A complete tutorial on the JavaScript History API. Covers history.pushState, replaceState, popstate event, history.back, forward, go, managing browser navigation state, building breadcrumb trails, state serialization limits, integration with single-page applications, and comparing History API with the newer Navigation API.
The History API lets JavaScript modify the browser URL and session history without triggering page reloads. It forms the foundation of client-side routing in single-page applications. This guide covers every method, event, and pattern.
History API Methods
| Method | Description | Page Reload |
|---|---|---|
pushState(state, title, url) | Add new entry to history | No |
replaceState(state, title, url) | Replace current entry | No |
back() | Go back one entry | No (same-origin) |
forward() | Go forward one entry | No (same-origin) |
go(n) | Go forward/back by n entries | No (same-origin) |
history.length | Number of entries in the stack | N/A |
history.state | State object of current entry | N/A |
pushState: Adding History Entries
// pushState(stateObject, title, url)
// - stateObject: any serializable data to associate with this entry
// - title: ignored by most browsers, pass ""
// - url: the new URL to display (must be same-origin)
history.pushState({ page: "products" }, "", "/products");
console.log(window.location.pathname); // "/products"
console.log(history.state); // { page: "products" }
// Push with query parameters
history.pushState(
{ page: "search", query: "javascript" },
"",
"/search?q=javascript"
);
// Push with hash
history.pushState({ section: "faq" }, "", "/about#faq");The URL changes in the address bar but no network request is made. The page does not reload.
replaceState: Modifying the Current Entry
// Replace current entry without adding to the stack
history.replaceState({ page: "home", scrollY: 0 }, "", "/");
// Common use: update state without creating a new history entry
history.replaceState(
{ ...history.state, lastVisited: Date.now() },
"",
window.location.href
);
// Fix a URL after redirect
history.replaceState(null, "", "/dashboard");pushState vs replaceState
| Feature | pushState | replaceState |
|---|---|---|
| History stack | Adds new entry | Modifies current entry |
| Back button | Creates a new back target | No new back target |
history.length | Increments by 1 | Stays the same |
| Use case | Page navigation | URL correction, state update |
popstate Event
The popstate event fires when the user navigates with the back/forward buttons or when history.back(), history.forward(), or history.go() is called:
window.addEventListener("popstate", (event) => {
console.log("Navigation occurred");
console.log("New state:", event.state);
console.log("New URL:", window.location.href);
if (event.state) {
renderPage(event.state.page);
} else {
renderPage("home");
}
});
function renderPage(page) {
const content = document.getElementById("content");
switch (page) {
case "products":
content.innerHTML = "<h1>Products</h1>";
break;
case "about":
content.innerHTML = "<h1>About</h1>";
break;
default:
content.innerHTML = "<h1>Home</h1>";
}
}popstate does NOT fire when pushState or replaceState is called. It only fires on actual navigation (back/forward).
Programmatic Navigation
// Go back one page
history.back();
// Go forward one page
history.forward();
// Go back 2 pages
history.go(-2);
// Go forward 3 pages
history.go(3);
// Reload current page
history.go(0);
// Check position
console.log("History length:", history.length);
console.log("Current state:", history.state);State Object Guidelines
// State must be serializable (structured clone algorithm)
// DO: plain objects, arrays, strings, numbers, booleans, Date, Map, Set, ArrayBuffer
history.pushState(
{
page: "article",
id: 42,
filters: ["javascript", "tutorial"],
scrollPosition: 350,
},
"",
"/articles/42"
);
// DON'T: functions, DOM nodes, class instances (methods are stripped)
// This loses the method:
// history.pushState({ render: () => {} }, "", "/bad");
// SIZE LIMIT: ~2MB for the state object (varies by browser)
// Firefox: 16 MB, Chrome: ~10-16 MB, Safari: ~2 MB
// For large data, store an ID in state and look up data elsewhere
history.pushState({ cacheKey: "search-results-abc" }, "", "/search");Navigation Manager
class NavigationManager {
constructor() {
this.routes = new Map();
this.beforeLeaveHooks = [];
this.afterNavigateHooks = [];
window.addEventListener("popstate", (event) => {
this.handlePopState(event);
});
}
register(path, handler) {
this.routes.set(path, handler);
}
async navigate(path, state = {}) {
// Run before-leave hooks
for (const hook of this.beforeLeaveHooks) {
const allowed = await hook(window.location.pathname, path);
if (!allowed) return false;
}
history.pushState({ path, ...state }, "", path);
this.render(path, state);
// Run after-navigate hooks
for (const hook of this.afterNavigateHooks) {
hook(path, state);
}
return true;
}
replace(path, state = {}) {
history.replaceState({ path, ...state }, "", path);
this.render(path, state);
}
handlePopState(event) {
const path = window.location.pathname;
const state = event.state || {};
this.render(path, state);
for (const hook of this.afterNavigateHooks) {
hook(path, state);
}
}
render(path, state) {
// Exact match
if (this.routes.has(path)) {
this.routes.get(path)(state);
return;
}
// Pattern matching
for (const [pattern, handler] of this.routes) {
const regex = this.pathToRegex(pattern);
const match = path.match(regex);
if (match) {
handler({ ...state, params: match.groups || {} });
return;
}
}
// 404
if (this.routes.has("*")) {
this.routes.get("*")(state);
}
}
pathToRegex(pattern) {
const escaped = pattern
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
.replace(/:(\w+)/g, "(?<$1>[^/]+)");
return new RegExp(`^${escaped}$`);
}
beforeLeave(hook) {
this.beforeLeaveHooks.push(hook);
return () => {
const idx = this.beforeLeaveHooks.indexOf(hook);
if (idx > -1) this.beforeLeaveHooks.splice(idx, 1);
};
}
afterNavigate(hook) {
this.afterNavigateHooks.push(hook);
return () => {
const idx = this.afterNavigateHooks.indexOf(hook);
if (idx > -1) this.afterNavigateHooks.splice(idx, 1);
};
}
}
// Usage
const nav = new NavigationManager();
nav.register("/", () => {
document.getElementById("app").innerHTML = "<h1>Home</h1>";
});
nav.register("/about", () => {
document.getElementById("app").innerHTML = "<h1>About</h1>";
});
nav.register("/articles/:id", ({ params }) => {
document.getElementById("app").innerHTML = `<h1>Article ${params.id}</h1>`;
});
nav.register("*", () => {
document.getElementById("app").innerHTML = "<h1>404 Not Found</h1>";
});
// Navigate
nav.navigate("/about");
nav.navigate("/articles/42", { title: "My Article" });For building a complete SPA router, see creating an SPA router with the JS History API.
Scroll Restoration
// Disable automatic scroll restoration
if ("scrollRestoration" in history) {
history.scrollRestoration = "manual";
}
// Save scroll position before navigating
function saveScrollPosition() {
history.replaceState(
{ ...history.state, scrollY: window.scrollY },
"",
window.location.href
);
}
// Restore scroll position on popstate
window.addEventListener("popstate", (event) => {
if (event.state && typeof event.state.scrollY === "number") {
requestAnimationFrame(() => {
window.scrollTo(0, event.state.scrollY);
});
}
});
// Save before every navigation
window.addEventListener("scroll", () => {
clearTimeout(window.scrollSaveTimer);
window.scrollSaveTimer = setTimeout(saveScrollPosition, 150);
});Preventing Accidental Navigation
// Warn user about unsaved changes
let hasUnsavedChanges = false;
window.addEventListener("beforeunload", (event) => {
if (hasUnsavedChanges) {
event.preventDefault();
// Modern browsers show a generic message
event.returnValue = "";
}
});
// For SPA navigation, use beforeLeave hooks
nav.beforeLeave(async (from, to) => {
if (hasUnsavedChanges) {
return confirm("You have unsaved changes. Leave this page?");
}
return true;
});Rune AI
Key Insights
- pushState changes URL without reload: No network request is made; the browser simply updates the address bar and history stack
- popstate fires only on back/forward: It does NOT fire when you call pushState or replaceState; render your own content on those calls
- State must be serializable: Functions and DOM nodes cannot be stored; use plain objects, arrays, and primitives
- Same-origin restriction: pushState URLs must share the same protocol, hostname, and port as the current page
- Save scroll position in state: Disable automatic
scrollRestoration, savescrollYinreplaceState, and restore onpopstate
Frequently Asked Questions
What is the difference between pushState and window.location?
Can pushState change the URL to a different origin?
Why does popstate not fire on pushState calls?
How much data can I store in the state object?
Does the History API work with hash-based URLs?
Conclusion
The History API enables URL manipulation and client-side navigation without page reloads. Use pushState for new navigation entries, replaceState for URL corrections, and popstate for handling back/forward navigation. Always save scroll positions, warn about unsaved changes, and keep state objects small. For building a complete SPA router, see creating an SPA router with the JS History API.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.