What are the rules for the state object passed to history.pushState() — how is it stored, what can it hold, and why is history.state null when a page first loads?
answer
- structured clone, not a reference
- methods and prototypes do not survive
- persisted to disk with the entry
- store a key, not the payload
- the load-created entry has no state
basics
~20 sThe state object is structured-cloned and stored with the session history entry, so it survives reloads and session restore but drops functions, DOM nodes and class prototypes. The entry created by the initial page load carries no state, so history.state is null until replaceState seeds it.
solid answer
~50 s`pushState(state, '', url)` serializes `state` with the structured clone algorithm — the same one behind `postMessage` and `structuredClone()` — and attaches the result to the new session history entry. Plain objects, arrays, `Map`, `Set`, `Date`, `RegExp`, typed arrays and cycles survive; functions, DOM nodes, `Symbol`s and class prototypes do not, so a class instance comes back as a plain object without its methods, and anything unclonable throws a `DataCloneError`. The browser persists the entry to disk, so state survives reload, Back after a cross-document trip, and often a browser restart via session restore — which is why it should hold a small key or route descriptor, not a fetched payload; browsers cap the size (Firefox documents a 16 MiB limit) and every byte is written out. Read the current entry's state synchronously with `history.state`; the load-created entry has none, so routers call `replaceState` at boot to give it the same shape as every entry they push.
code
javascript · 21 lines// Seed the load-created entry so every entry has the same shape.
if (!history.state) {
history.replaceState(
{ key: crypto.randomUUID(), path: location.pathname },
'',
location.href,
);
}
const scrollByKey = new Map();
function navigate(path) {
scrollByKey.set(history.state.key, window.scrollY); // payload stays in memory
history.pushState({ key: crypto.randomUUID(), path }, '', path);
render();
}
window.addEventListener('popstate', (e) => {
const state = e.state ?? { key: null, path: location.pathname }; // may be null
render(state.path);
});go deeper
Know that pushState takes a state object, that you read it back with history.state, and that it must be plain data — not a DOM node or a function.
Explain structured clone versus reference: mutating the original changes nothing, class instances lose their prototype, unclonable values throw DataCloneError, and updating stored state means calling replaceState again.
Reason about the entry being persisted to disk — size caps, session restore, staleness — and about seeding the initial null state with replaceState at boot so traversal back to the landing entry does not crash the router.
Treat history state as a durable, cross-version data contract owned by the user's browser: version it, validate it defensively on read, keep sensitive data out of it, and prefer deriving state from the URL wherever the URL can express it.
## What "state" is Each session history entry can carry an arbitrary serializable value alongside its URL. `history.pushState(state, '', url)` sets it on a new entry, `history.replaceState(state, '', url)` on the current one, `history.state` reads the current one, and the traversal event's `event.state` delivers the target one. That is the whole surface. The point of it is to store what the URL cannot express. A URL says *which* view; state can say how the user got there — the scroll offset of a list, an ephemeral form draft, a router-generated key, whether this entry was a modal opened over a page. ## The serialization rules The value is copied by the **structured clone algorithm**, not by reference and not by `JSON.stringify`. Consequences: - **Supported**: plain objects and arrays, all primitives including `BigInt`, `Date`, `RegExp`, `Map`, `Set`, `Blob`, `File`, `ArrayBuffer` and typed arrays, and cyclic references. - **Rejected with a `DataCloneError`**: functions, `Symbol`s, and objects holding them; DOM nodes; `Window`. - **Silently degraded**: class instances. Their own enumerable properties are copied, but the prototype is not, so `state instanceof Route` is `false` after a round trip and every method is gone. Because it is a copy, mutating the object after pushing changes nothing: ```js const s = { count: 1 }; history.pushState(s, '', '/a'); s.count = 2; history.state.count; // 1 — the entry holds an independent clone ``` And because reading also produces a copy in current browsers, `history.state.count++` writes to a throwaway object. To change stored state you must call `replaceState` with a new value. ## Persistence, size, and what belongs in there The entry — URL and state together — is written to the browser's session store on disk, not just kept in memory. That gives it a much longer life than a JavaScript variable: it survives a reload, it survives going to another site and pressing Back, and with session restore it can survive quitting the browser. Every write therefore costs I/O. Browsers cap the serialized size; Firefox documents a 16 MiB limit per state object and throws when it is exceeded, and other engines impose their own. The practical rule is stricter than any cap: **store an identifier, not the data.** Put a key in the state and keep the payload in memory, `sessionStorage`, or a cache keyed by that identifier. This keeps the history store small and avoids resurrecting stale data — an eight-minute-old API response restored from disk on a Back press is usually worse than a refetch. A second reason to keep it small and dull: it is persisted user data. Anything sensitive placed there sits on disk in the profile directory long after the tab is gone. ## The null-state problem The entry created by an ordinary page load has no state, so: - `history.state` is `null` right after load. - Traversing back to that entry delivers `popstate` with `event.state === null`. A router that does `event.state.routeKey` therefore throws exactly once — when the user returns to the page they first landed on, which is a very common path and a rare one in local testing. The standard remedy is to seed the first entry during startup: ```js if (!history.state) { history.replaceState({ key: crypto.randomUUID(), path: location.pathname }, '', location.href); } ``` Use `replaceState`, never `pushState`, or booting the app would insert a phantom entry the user has to walk back through. The `if (!history.state)` guard matters because a reload of an entry that already had state must not lose it. ## Other rules worth knowing - The URL argument must be **same-origin**; a cross-origin value throws a `SecurityError` on both `pushState` and `replaceState`. Path, query and fragment are all free to change. - The second argument is a title that no browser applies. Pass `''`. - Never trust the state's shape when reading it. It may have been written by a previous deploy of your app whose router stored a different structure, and the user's Back button will hand it to today's code. Version it, or validate defensively and fall back to deriving everything from `location`.
- Why is storing a fetched API response in the state object a bad idea even when it fits under the size cap?The state is persisted to disk with the entry and can be restored hours later by Back or session restore, so you would render stale data with no cue that it is stale. Every write also costs I/O and profile space. Store a cache key and refetch or read from an in-memory cache on restore.
- You push a class instance as state and read it back after a Back press. What do you get?A plain object with the instance's own enumerable properties and nothing else. Structured clone does not carry prototypes, so instanceof is false and every method is gone. Serialize to a plain descriptor deliberately and reconstruct the instance yourself on read.
- An old deploy pushed state shaped {view}; the new router expects {routeKey}. What happens on Back?The old entry is still on disk and the user traverses to it, so the new code receives the old shape. Validate the state on every read, treat an unknown shape as absent, and derive the view from location instead. A version field in the state makes that check explicit.
saying these in an interview costs you the question
- Assumes the state object is stored by reference
- Mutates history.state directly to update it
- Stores whole API responses in history state
- Reads event.state without handling null
- Thinks JSON.stringify rules apply to the clone