pushState & replaceState Usage
Single-page applications stand or fall on how precisely they keep the address bar in step with what the user is actually looking at. The two methods that make that possible are history.pushState() and history.replaceState(): they let you rewrite the URL and attach an arbitrary state object without a full page reload or a network round-trip. Used carelessly they corrupt the back button, leak memory, and silently drop data; used well they are the quiet machinery behind client-side routing, deep linking, and instant view transitions. This page covers the production patterns — validation, size limits, error handling, and verification — that separate a toy router from one you can ship.
← Back to History API & State Management
The Problem
Calling pushState looks deceptively simple, so most bugs surface only in production. Three failure shapes dominate.
First, stack pollution. Every keystroke in a search box or every page in an infinite scroll that calls pushState adds a session history entry. After fifty filter changes, a single press of the back button moves the user one filter back instead of off the page, and they feel trapped. This is the single most common complaint against custom routers and the reason Preventing Duplicate History Entries with replaceState exists as its own topic.
Second, silent data loss and crashes. The state object is structured-cloned, not JSON-stringified, so functions, DOM nodes, and class instances throw a DataCloneError. Oversized payloads throw a DOMException in Chromium once the serialised structure crosses roughly 2 MB (the historical 640 KB figure is now far higher in current Chrome, but treating it as a hard guarantee is unwise — Firefox enforces its own separate cap). Without a guard, one large object turns a navigation into an unhandled exception.
Third, desynchronisation on restore. Because these methods are the foundation of the History API & State Management layer, anything that resets the DOM without firing your normal navigation path — most notably the back/forward cache (bfcache) on iOS Safari — leaves your in-memory state and the rendered DOM disagreeing. The address bar says one thing, the screen shows another.
A correct implementation closes all three gaps before it ever ships a feature on top. None of these failures throw at the moment you write the bad code; they surface as confused users, climbing memory graphs, and intermittent “it works on my machine” bug reports. That delayed feedback is exactly why a thin, well-guarded wrapper around these two methods pays for itself — the cost of validation is a few microseconds, the cost of skipping it is a broken back button shipped to production.
Core API & Primitives
Both methods share one signature. The browser declares them roughly like this:
// TypeScript 5.x — lib.dom.d.ts, framework-agnostic
interface History {
pushState(data: unknown, unused: string, url?: string | URL | null): void;
replaceState(data: unknown, unused: string, url?: string | URL | null): void;
readonly state: unknown;
}
The three positional arguments behave as follows.
data— any structured-cloneable value. It is stored against the history entry and read back later fromhistory.stateor from aPopStateEvent.state. It is not the same object reference; it is a deep clone.unused(the oldtitle) — ignored by every current browser. Pass an empty string and never rely on it.url— must be same-origin as the current document, or the call throws aSecurityError. A relative URL resolves against the current entry. Omitting it keeps the current URL but still updates the state.
The behavioural distinction is the whole point: pushState appends a new entry (back/forward can return to the previous one), while replaceState overwrites the current entry in place, leaving stack depth unchanged. Neither method fires a popstate event — that event only fires on user-driven back/forward navigation or history.go(), which is why popstate Event Handling is a separate concern you must wire up yourself.
A useful mental model: pushState is for navigation a user would expect the back button to undo; replaceState is for refining the current location (a corrected filter, a debounced query, a canonicalised slug) where a back-button stop would be noise.
It is also worth being precise about what these methods do not do. They never trigger a network request, never re-run the document, and never validate that the target URL resolves to anything — you can push /this-route-does-not-exist and the browser will happily show it in the address bar. Responsibility for rendering the matching view is entirely yours; the History API only moves the pointer and stores the state. Likewise, the data argument and the url argument are independent: you can change the URL without changing the state, or change the state while keeping the URL, by passing the same path twice. Treating these as two separate channels — one for the public, shareable address and one for private, ephemeral routing metadata — keeps both clean and avoids overloading the URL with data that belongs in state.
Step-by-Step Implementation
Prerequisite: a same-origin document served over http(s) (these APIs are inert on file://), and TypeScript 5.x if you want the typings below — the runtime logic is framework-agnostic.
Step 1: Guard the payload before you write it
Validate that the state is cloneable and within a conservative size budget. Doing this yourself gives a graceful warning instead of a thrown navigation.
// TypeScript 5.x — framework-agnostic
interface RouteState {
pageId: string;
filters?: Record<string, string>;
timestamp: number;
}
const MAX_STATE_BYTES = 512_000; // conservative; well under any browser cap
function assertWritableState(state: unknown): void {
// structuredClone throws DataCloneError on functions, DOM nodes, class instances
structuredClone(state);
// Size is approximate: JSON length is a cheap proxy for the cloned footprint
const approxBytes = new Blob([JSON.stringify(state)]).size;
if (approxBytes > MAX_STATE_BYTES) {
throw new RangeError(
`History state ~${approxBytes}B exceeds ${MAX_STATE_BYTES}B budget`
);
}
}
Step 2: Wrap pushState and replaceState in a single safe entry point
One function, one error-handling policy. Distinguishing SecurityError (a programming bug — wrong origin) from a size failure lets each be handled correctly rather than swallowed.
// TypeScript 5.x — framework-agnostic
type HistoryMethod = "push" | "replace";
function safeHistoryUpdate(
method: HistoryMethod,
state: RouteState,
url: string
): boolean {
try {
assertWritableState(state);
} catch (err) {
console.warn("Rejected history update:", (err as Error).message);
return false; // caller decides whether to prune and retry
}
try {
if (method === "push") {
history.pushState(state, "", url);
} else {
history.replaceState(state, "", url);
}
return true;
} catch (err) {
if (err instanceof DOMException && err.name === "SecurityError") {
console.error("Cross-origin URL rejected:", url);
return false;
}
throw err; // unexpected — let it surface
}
}
Step 3: Prune heavy state into reference IDs
Never store a dataset in history. Keep an identifier and refetch on demand; the entry stays tiny and survives serialisation across reloads.
// TypeScript 5.x — framework-agnostic
function pruneHistoryState(
state: Record<string, unknown>
): Record<string, unknown> {
const { largeDataset, uiCache, ...essential } = state as {
largeDataset?: { id?: string };
uiCache?: unknown;
[key: string]: unknown;
};
return {
...essential,
_dataRef: largeDataset?.id ?? null,
_prunedAt: Date.now(),
};
}
Step 4: Expose navigate() and subscribe through a small router
This is where pushState/replaceState meet your application. Listeners are notified on both programmatic navigation and popstate, which is what makes Deep Linking Implementation and view restoration possible.
// TypeScript 5.x — framework-agnostic
class CustomRouter {
private currentPath: string;
private listeners = new Set<(path: string, state: unknown) => void>();
private readonly onPopState = (e: PopStateEvent) => {
this.currentPath = window.location.pathname;
this.notify(e.state);
};
constructor() {
this.currentPath = window.location.pathname;
window.addEventListener("popstate", this.onPopState);
}
navigate(path: string, state: RouteState, replace = false): void {
const ok = safeHistoryUpdate(replace ? "replace" : "push", state, path);
if (!ok) return;
this.currentPath = path;
this.notify(state);
}
subscribe(cb: (path: string, state: unknown) => void): () => void {
this.listeners.add(cb);
return () => this.listeners.delete(cb);
}
private notify(state?: unknown): void {
const finalState = state ?? history.state;
this.listeners.forEach((cb) => cb(this.currentPath, finalState));
}
destroy(): void {
// same bound reference as addEventListener — removal actually works
window.removeEventListener("popstate", this.onPopState);
}
}
Note that the listener is stored as a single bound arrow function. Binding inline at both addEventListener and removeEventListener (a common mistake) creates two different function references, so the removal silently does nothing and the listener leaks.
Step 5: Re-sync after a bfcache restore
The bfcache restores the DOM without re-running your navigation path and without firing popstate. Catch it via pageshow and replay your sync.
// TypeScript 5.x — framework-agnostic
function handleBfcacheRestoration(router: { resync: (s: unknown) => void }): void {
window.addEventListener("pageshow", (event) => {
if (event.persisted) {
// Page came from bfcache; in-memory state may be stale
router.resync(history.state);
}
});
}
function detectHistorySupport(): boolean {
return !!(window.history && "pushState" in window.history);
}
Verification & Testing
The reliable way to prove the back button still works is an end-to-end test that counts entries. Playwright reads history.length and the live URL, so you can assert that replaceState did not grow the stack while pushState did.
// @playwright/test v1.44 — Playwright Test
import { test, expect } from "@playwright/test";
test("replaceState refines without polluting the back stack", async ({ page }) => {
await page.goto("/products");
const startLen = await page.evaluate(() => history.length);
// Two filter refinements should replace, not push
await page.evaluate(() => {
history.replaceState({ filter: "a" }, "", "/products?f=a");
history.replaceState({ filter: "ab" }, "", "/products?f=ab");
});
expect(await page.evaluate(() => history.length)).toBe(startLen);
await expect(page).toHaveURL(/f=ab/);
expect(await page.evaluate(() => (history.state as any).filter)).toBe("ab");
// A real navigation should push exactly one entry
await page.evaluate(() => history.pushState({ page: 2 }, "", "/products?p=2"));
expect(await page.evaluate(() => history.length)).toBe(startLen + 1);
await page.goBack();
await expect(page).toHaveURL(/f=ab/);
});
For a quick manual check, run history.length in the DevTools console before and after an action; if a “refine” interaction increments it, you are using pushState where replaceState belongs.
Performance Tuning
- Debounce high-frequency writes. Search-as-you-type and slider drags can fire dozens of updates per second. Coalesce them and write once with
replaceState, so neither the stack nor the main thread is hammered. - Decouple from the render path. Treat the URL write as a side effect after paint, not a dependency of it. Writing inside a reactive render in React or Vue can cause redundant re-renders and drop you below 60 fps during rapid interaction.
- Measure, do not guess. The DevTools Performance panel timeline exposes the moments where rapid
pushStatecalls outrun your UI updates and produce visible desynchronisation.
// TypeScript 5.x — framework-agnostic
function createDebouncedStateUpdater(delayMs = 250) {
let timer: ReturnType<typeof setTimeout> | null = null;
let pending: { state: RouteState; url: string } | null = null;
return (state: RouteState, url: string): void => {
pending = { state, url };
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
if (pending) safeHistoryUpdate("replace", pending.state, pending.url);
timer = null;
pending = null;
}, delayMs);
};
}
Gotchas & Failure Modes
- The state object is cloned, not stringified. Storing a function, DOM node, or class instance throws
DataCloneError. Plain serialisable data only — validate before you write. - Neither method fires
popstate. If you rely on a singlepopstatehandler to update the UI, programmatic navigation will appear to do nothing. Notify your listeners explicitly after each write. - Replacing the very first entry can break the back button. Calling
replaceStateon initial load overwrites the entry the user arrived on; for the first in-app navigation there may be nothing meaningful to return to. - Size limits are real but inconsistent. Chromium and Firefox enforce different caps and they change between versions. Budget conservatively (well under 1 MB) and keep heavy data out of state entirely.
- iOS Safari bfcache restores stale DOM without
popstate. Usepageshowwithevent.persistedto re-sync, or the screen and the URL drift apart. - Same router instance, two bound handlers. Adding and removing
popstatelisteners with separately-bound functions leaks the listener. Store one reference and reuse it.
Go Deeper
- Preventing Duplicate History Entries with replaceState — when to swap
pushStateforreplaceStateand how to debounce filter and pagination updates so the back button stays usable.
Related
- History API & State Management — the parent area covering session history, state objects, and navigation events end to end.
- popstate Event Handling — capturing back/forward navigation and restoring UI state, the read side of what pushState writes.
- Deep Linking Implementation — encoding application state into shareable URLs that rehydrate on load.
- Preventing Duplicate History Entries with replaceState — focused techniques for keeping the history stack clean during high-frequency interactions.