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.

JavaScriptintermediate
15 min read

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

MethodDescriptionPage Reload
pushState(state, title, url)Add new entry to historyNo
replaceState(state, title, url)Replace current entryNo
back()Go back one entryNo (same-origin)
forward()Go forward one entryNo (same-origin)
go(n)Go forward/back by n entriesNo (same-origin)
history.lengthNumber of entries in the stackN/A
history.stateState object of current entryN/A

pushState: Adding History Entries

javascriptjavascript
// 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

javascriptjavascript
// 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

FeaturepushStatereplaceState
History stackAdds new entryModifies current entry
Back buttonCreates a new back targetNo new back target
history.lengthIncrements by 1Stays the same
Use casePage navigationURL 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:

javascriptjavascript
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

javascriptjavascript
// 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

javascriptjavascript
// 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");
javascriptjavascript
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

javascriptjavascript
// 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

javascriptjavascript
// 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

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, save scrollY in replaceState, and restore on popstate
RunePowered by Rune AI

Frequently Asked Questions

What is the difference between pushState and window.location?

`pushState` changes the URL without reloading the page or making a network request. Setting `window.location` or `window.location.href` triggers a full page navigation with a network request. Use `pushState` for SPA routing and `window.location` for actual page transitions.

Can pushState change the URL to a different origin?

No. The URL in `pushState` must be the same origin (protocol + hostname + port) as the current page. Attempting to push a cross-origin URL throws a `SecurityError`. For cross-origin navigation, use `window.location.href` instead.

Why does popstate not fire on pushState calls?

By design, `popstate` only fires when the user navigates via back/forward buttons or `history.back()/forward()/go()`. This avoids infinite loops and gives you control over rendering when you initiate navigation with `pushState`. Handle your own rendering in the `navigate()` function.

How much data can I store in the state object?

Browser limits vary: Firefox allows ~16MB, Chrome ~10-16MB, Safari ~2MB. For safety, keep state objects small (under 1MB) and store large data in [JS localStorage](/tutorials/programming-languages/javascript/js-localstorage-api-guide-a-complete-tutorial) or [sessionStorage](/tutorials/programming-languages/javascript/js-sessionstorage-api-guide-complete-tutorial) with a reference key in the state.

Does the History API work with hash-based URLs?

Yes. You can use `pushState` with URLs containing hashes (`/page#section`). However, hash changes also fire the `hashchange` event. For SPA routing, choose either hash-based routing (`#/path`) or path-based routing (`/path`) but avoid mixing them.

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.