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.

What structured clone accepts, transforms, and rejects Three columns. Accepted values include plain objects, arrays, dates, maps, sets, typed arrays and cyclic references. Transformed values include class instances, which lose their prototype and methods. Rejected values include functions, DOM nodes, proxies and weak collections, which throw a DataCloneError. Cloned faithfully plain objects, arrays strings, numbers, booleans Date, RegExp Map, Set ArrayBuffer, typed arrays Blob, File cyclic references round-trips exactly Silently transformed class instances fields survive prototype is stripped methods are gone getters become values No error on write. Fails on the next back press. Rejected functions DOM nodes Proxy WeakMap, WeakSet Error (partially) throws DataCloneError at least this one fails loudly
The dangerous column is the middle one: class instances are accepted, stripped of their prototype, and only break on the way back.

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");
}
Storing references instead of data in a history entry Two versions of the same history entry. The first embeds a full result set of four hundred kilobytes. The second stores a query key and a scroll offset totalling forty bytes, with the data held in an in-memory cache keyed by that query key. Data in the entry history.state = { rows: [ …2,000 records… ], scroll: 1840 } ≈ 400 KB per entry × every navigation in the session Reference in the entry, data in a cache history.state = { q: "laptops:2", scroll: 1840 } ≈ 40 bytes in-memory cache: Map<queryKey, rows> refetch on a cache miss — the key still identifies the data
The entry should identify the state, not contain it; a cache miss costs one refetch, while a fat entry costs memory on every navigation.

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.state is null on the first load unless you replaceState during initialisation. Do that early, or every read needs a null branch.
  • replaceState overwrites 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.
Five places per-navigation state can live Five storage options compared by whether they survive a reload and what they are scoped to. Only history state is scoped to a single history entry, which is what makes it right for per-entry data such as scroll offsets. Three stores, three different lifetimes survives a reload? scoped to history.state yes, within the tab session one history entry sessionStorage yes, within the tab session the whole tab localStorage yes, indefinitely the origin, all tabs in-memory Map no the page instance the query string yes, and it is shareable the URL itself
Only the first row is scoped to one history entry — which is exactly why scroll offsets and list positions belong there.

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.