Serialising Complex State into History Entries
After reading this you will be able to store rich state in a history entry that survives back, forward and reload — knowing exactly which value types the structured clone algorithm accepts, how to keep entries small, and how to version them so an old entry cannot crash a new build.
← Back to pushState & replaceState Usage
Prerequisites
Core Concept
The first argument to pushState and replaceState is a state object attached to that session-history entry. It is stored by the browser, persists across reloads within the session, and is handed back on popstate and via history.state. It is invisible to the user, invisible to crawlers, and never appears in the URL — which makes it the right home for anything that must survive back navigation but should not be shareable.
The value is not JSON-serialised. It goes through the structured clone algorithm, which is both more and less permissive than JSON.stringify. More: it handles Date, RegExp, Map, Set, ArrayBuffer, typed arrays, Blob, File, and — importantly — cyclic references. Less: it throws a DataCloneError on functions, DOM nodes, WeakMap, Proxy, and any class instance whose prototype it cannot reconstruct.
That last category is the practical trap. A class instance is not rejected outright; it is cloned as a plain object with the prototype stripped. The methods are gone. Code that stored a Cart and reads back something that walks like a Cart but has no total() fails on the next back navigation, not on the write — which is why the bug is nearly always reported as “the back button is broken” rather than as a serialisation problem.
The other constraint is size, and it is enforced very differently across browsers: Firefox rejects beyond roughly 16 MB, Safari has historically been far stricter at around 2 MB per entry and a lower per-session budget, and Chrome accepts a great deal but pays for it in memory. The practical rule is to treat a few kilobytes as the budget and store references rather than data.
Implementation
Define the state shape as a plain, versioned interface. Versioning is not optional in anything long-lived: a session-history entry written by yesterday’s build will be read by today’s after a deploy, and the reader must be able to recognise a shape it no longer understands.
// TypeScript 5.x — a versioned, clone-safe history state shape
/** Bump when the shape changes incompatibly. Old entries are then ignored. */
const STATE_VERSION = 3;
export interface RouteHistoryState {
v: number;
/** Scroll offsets keyed by a scroll-container selector. */
scroll: Record<string, number>;
/** A selector for the element that had focus, for back-navigation restore. */
focusKey?: string;
/** IDs only — never the fetched objects themselves. */
selection: string[];
/** Where the user came from, for "back to results" affordances. */
from?: { pathname: string; search: string };
}
export function createState(partial: Omit<RouteHistoryState, "v">): RouteHistoryState {
return { v: STATE_VERSION, ...partial };
}
Reading must be defensive, because the value can be null (a fresh entry), a shape from an older build, or something another library on the page wrote:
// TypeScript 5.x — never trust history.state
export function readState(): RouteHistoryState | null {
const raw: unknown = history.state;
if (raw === null || typeof raw !== "object") return null;
const candidate = raw as Partial<RouteHistoryState>;
if (candidate.v !== STATE_VERSION) return null; // old build — ignore, do not migrate blindly
if (typeof candidate.scroll !== "object" || candidate.scroll === null) return null;
return candidate as RouteHistoryState;
}
Writing needs two guards: a clone check and a size check. Both are cheap and both turn a production mystery into a development error.
// TypeScript 5.x — fail fast in development, degrade gracefully in production
const SOFT_LIMIT_BYTES = 8 * 1024; // well under every browser's ceiling
export function writeState(url: string, state: RouteHistoryState, mode: "push" | "replace"): void {
try {
// structuredClone throws on the same values pushState would reject, so this
// surfaces the problem at the call site with a usable stack trace.
structuredClone(state);
} catch (error) {
console.error("history state is not structured-cloneable", { state, error });
return; // navigate without state rather than breaking the navigation
}
// A rough size proxy; JSON undercounts binary values but is fine as a guard.
const approxBytes = new Blob([JSON.stringify(state)]).size;
if (approxBytes > SOFT_LIMIT_BYTES) {
console.warn(`history state is ${approxBytes} bytes — store references, not data`);
}
if (mode === "push") history.pushState(state, "", url);
else history.replaceState(state, "", url);
}
The pattern that keeps entries small is to store keys, not values. A list position is an id and an offset, not the list. A selected record is an id, not the record.
// TypeScript 5.x — references in history, data in a cache keyed by those references
const resultsCache = new Map<string, unknown[]>(); // lives in memory, not in history
export function rememberResults(queryKey: string, rows: unknown[]): void {
resultsCache.set(queryKey, rows);
// The entry stores 40 bytes instead of 400 kilobytes.
writeState(location.href, createState({ scroll: {}, selection: [], from: undefined }), "replace");
}
Verification
// @playwright/test v1.44
import { test, expect } from "@playwright/test";
test("history state round-trips through a back navigation", async ({ page }) => {
await page.goto("/products?category=laptops");
await page.evaluate(() => window.scrollTo(0, 1840));
await page.getByRole("link", { name: "First result" }).click();
await page.goBack();
const state = await page.evaluate(() => history.state);
expect(state.v).toBe(3);
expect(state.scroll.window).toBe(1840);
// And it must still be small.
const size = await page.evaluate(() => JSON.stringify(history.state).length);
expect(size).toBeLessThan(8 * 1024);
});
test("a non-cloneable value does not break navigation", async ({ page }) => {
await page.goto("/products");
const survived = await page.evaluate(() => {
try {
history.pushState({ fn: () => 1 }, "", "/products?x=1");
return "no error";
} catch (e) {
return (e as Error).name; // DataCloneError
}
});
expect(survived).toBe("DataCloneError");
});
Manually, keep history.state visible in the DevTools console while navigating. Two things are worth watching: that it is never null on a route your router wrote, and that its size stays flat as the session grows. A steadily growing entry usually means something is accumulating rather than being replaced.
Gotchas
- Class instances lose their methods. They clone into plain objects, so the failure appears on the next back navigation rather than on the write. Store plain data and rehydrate explicitly.
history.stateisnullon the first load unless youreplaceStateduring initialisation. Do that early, or every read needs a null branch.replaceStateoverwrites the entire state object. There is no merge; read it first, spread it, and write the whole thing back.- Unversioned state breaks across deploys. A user with an open tab keeps entries written by the previous build; the version field is what lets you ignore them safely.
- Safari’s limits are the ones that bite. A payload comfortable in Chrome can throw in Safari, and the throw aborts the navigation entirely.
- Do not put secrets in history state. It survives reload and is readable from the console by anything running on the page.
FAQ
Is history.state the same as sessionStorage? No. History state is scoped to a single session-history entry, so each back step has its own copy; sessionStorage is one shared bucket per tab. Per-entry data — scroll offsets, list positions — belongs in history state.
Does it survive a page reload? Yes, within the same tab session. It does not survive closing the tab, and it is not shared with other tabs.
How big is too big? Treat a few kilobytes as the working budget. The hard ceilings are far higher on Chrome and Firefox, but Safari is stricter and a rejected write aborts the navigation.
Can I store a Map or a Date? Yes — structured clone handles both faithfully, unlike JSON. It is one of the few places in web APIs where you can rely on that.
What happens if pushState throws? The navigation does not happen. Wrapping the write and falling back to a stateless pushState keeps the application usable when a payload turns out to be too large.
Related
- pushState & replaceState Usage — the write primitives and when each is appropriate.
- History State Size Limits Across Browsers — the concrete ceilings and how they fail.
- Query String State Sync — where shareable state belongs instead of the state object.