History State Size Limits Across Browsers
After reading this you will be able to measure the serialised size of any history.state object, guard a pushState call so it never throws in Safari, and split a heavy payload into a small id kept in history plus the bulk parked in sessionStorage or IndexedDB.
← Back to pushState & replaceState Usage
Prerequisites
Core Concept
The first argument to history.pushState and history.replaceState is not stored as a string — the browser runs it through the structured-clone algorithm and keeps the serialised result in the session history entry, which is part of the History API & State Management surface. Because that entry is persisted to disk for session restore and the back-forward cache, engines cap how large it may be. WebKit (Safari) enforces the tightest and most famous limit: a serialised state object larger than roughly 640kB throws a DataCloneError and the navigation is rejected outright, so the URL never changes. Chromium and Firefox are far more generous in practice — you will usually hit a memory or session-restore penalty long before a hard throw — but relying on their headroom is how an app that works on Chrome breaks the moment a Safari user has a slightly larger cart or filter set. The safe design treats history.state as a tiny, structured-clone-safe key and keeps the real weight elsewhere.
Two failure modes matter and they are different: an over-limit object in Safari throws synchronously (you can catch it), whereas a value that survives the clone but is bloated silently costs you memory and slower session restore with no error at all. Good code measures before it pushes, keeps the object well under the smallest engine cap, and never assumes a push succeeded just because no exception was raised on the current browser.
Implementation
The helper below measures the serialised byte length of a candidate state, refuses anything above a conservative budget well below Safari’s cap, and transparently offloads the heavy portion to sessionStorage while pushing only a compact reference into history.
// TypeScript 5.x — framework-agnostic, no runtime dependencies
// Stay well under WebKit's ~640kB DataCloneError threshold. A conservative
// 512kB budget leaves headroom for the entry's own bookkeeping overhead.
const MAX_STATE_BYTES = 512 * 1024;
interface HistoryRef {
// Only a pointer lives in history.state; the payload lives in storage.
ref: string;
}
// Structured clone keeps raw JS, but a JSON round-trip via a Blob gives a
// reliable byte count that closely tracks the serialised on-disk size.
function stateByteSize(value: unknown): number {
return new Blob([JSON.stringify(value)]).size;
}
export function pushStateSafely(payload: unknown, url: string): void {
const inlineSize = stateByteSize(payload);
if (inlineSize <= MAX_STATE_BYTES) {
// Small enough to store inline — no external storage needed.
history.pushState(payload, "", url);
return;
}
// Too heavy for history.state: keep an id inline, park the bulk in
// sessionStorage so Safari never throws a DataCloneError on push.
const ref = crypto.randomUUID();
try {
sessionStorage.setItem(`hist:${ref}`, JSON.stringify(payload));
} catch (err) {
// Storage can also be full; surface it rather than pushing a dangling ref.
throw new Error(`Could not offload ${inlineSize} bytes of state`, { cause: err });
}
const stub: HistoryRef = { ref };
history.pushState(stub, "", url);
}
// Rejoin the two halves when an entry is restored (e.g. inside popstate).
export function readState<T>(raw: unknown): T | null {
if (raw && typeof raw === "object" && "ref" in raw) {
const stored = sessionStorage.getItem(`hist:${(raw as HistoryRef).ref}`);
return stored ? (JSON.parse(stored) as T) : null;
}
return (raw as T) ?? null;
}
Wrapping the raw pushState in a try/catch is still worth doing even with the size guard, because a value can pass a byte-count check yet contain something the structured-clone algorithm rejects — a Function, a DOM node, or a class instance — which throws regardless of size.
Verification
Drive a real push in each engine and assert both that the URL changed and that the round-tripped payload survives. WebKit is the engine that will actually throw, so run the same spec against it explicitly.
// @playwright/test v1.44 — run with --project=webkit, firefox, chromium
import { test, expect } from "@playwright/test";
test("large state is offloaded, not thrown", async ({ page }) => {
await page.goto("/search/");
const changed = await page.evaluate(() => {
// ~1MB payload — over Safari's inline limit, must be offloaded.
const big = { rows: Array.from({ length: 20000 }, (_, i) => ({ i, v: "x".repeat(40) })) };
(window as unknown as { pushStateSafely: (p: unknown, u: string) => void })
.pushStateSafely(big, "/search/?q=big");
return location.search;
});
expect(changed).toContain("q=big");
// history.state must be the tiny stub, not the megabyte payload.
const stateSize = await page.evaluate(() => JSON.stringify(history.state).length);
expect(stateSize).toBeLessThan(200);
});
For a quick manual check, open DevTools and run new Blob([JSON.stringify(history.state)]).size on any page to see the live serialised size of the current entry. In Safari, paste a history.pushState({ big: "x".repeat(700000) }, "", location.href) into the console — it throws DataCloneError where Chrome and Firefox quietly accept it, which is the whole reason to budget for the smallest cap.
Gotchas
- The ~640kB WebKit limit is on the serialised size, not the number of keys — a deeply nested object or a large typed array can blow past it while looking small in the source, so measure bytes, not properties.
sessionStoragehas its own quota (commonly around 5MB per origin) and is per-tab; if you offload there, handle theQUOTA_EXCEEDEDthrow and prefer IndexedDB when payloads are large or shared across tabs.- Offloaded entries can leak — a
sessionStoragekey survives the history entry that referenced it; prune stalehist:keys on load or use IndexedDB with an eviction policy so old navigations do not accumulate. - A byte-count guard does not catch non-cloneable values; a
Functionor DOM node still throwsDataCloneErroron push, so keep the payload to plain JSON, which also helps Scroll Restoration Strategies persist cleanly.
FAQ
What is the actual size limit on history.state? Only WebKit publishes a hard cap — roughly 640kB of serialised state, above which pushState and replaceState throw a DataCloneError. Chromium and Firefox impose no small documented limit and instead degrade through memory pressure and slower session restore, so budget for Safari’s threshold to stay portable.
Does pushState throw or silently truncate when state is too big? In Safari it throws a DataCloneError synchronously and the navigation is rejected, so the URL does not change. It never truncates — you either get the whole object or an exception, which is why you should catch the throw or measure the size before pushing.
How do I measure the size of a history.state object? Serialise it and count bytes: new Blob([JSON.stringify(value)]).size gives a reliable figure that closely tracks the on-disk serialised size. Run it in DevTools against history.state to audit a live page, and gate your pushState calls on the same number.
Should I store large data in history.state or somewhere else? Keep only a small id or reference in history.state and store the heavy payload in sessionStorage or IndexedDB keyed by that id. This keeps every entry far under the Safari cap, speeds up session restore, and lets you rejoin the two halves when the entry is restored on popstate.
Why does my state work in Chrome but break in Safari? Chrome tolerates much larger state objects than WebKit, so a payload that pushes cleanly in Chrome can exceed Safari’s ~640kB limit and throw a DataCloneError there. Test in Safari specifically and budget against its smaller cap rather than Chrome’s headroom.
Related
- pushState & replaceState Usage — the parent guide to writing history entries and the state objects they carry.
- pushState vs replaceState: When to Use Each — choosing which method to call before you worry about how big the state can be.
- Cross-Browser popstate Event Quirks — reading state back reliably when the back button restores an entry.