Cross-Browser popstate Event Quirks
After reading this you will be able to write a popstate handler that behaves identically whether a user taps back in Safari, clicks forward in Firefox, or swipes the trackpad in a Chromium browser — neutralising the three engine-specific quirks that silently break SPA state restoration.
← Back to popstate Event Handling
Prerequisites
Core Concept
The popstate event is part of the History API & State Management surface, but the specification leaves three timing decisions to the implementer: whether the event fires on initial document load, when it fires relative to bfcache restoration, and how history.state is serialised. Each major engine resolved those decisions differently, so a handler that works in one browser can fire at the wrong moment, fire twice, or receive a null state in another.
The three quirks worth memorising: WebKit (Safari) restores pages from the bfcache and may deliver popstate with a stale or already-rendered DOM, so you must also listen for pageshow with event.persisted; Gecko (Firefox) historically fired a spurious popstate on initial load and is the strictest about the structured-clone serialisation of state objects; Blink (Chromium) fires synchronously and reliably but evicts the bfcache aggressively, turning what looks like a back-navigation into a full reload that resets your in-memory state. A robust handler treats popstate and pageshow as one normalised navigation signal and never trusts e.state to be present.
Implementation
The controller below collapses both events into a single onNavigate callback, falls back to sessionStorage when an engine drops the state payload, and guards against the duplicate-fire race that rapid back/forward clicking produces.
// TypeScript 5.x — framework-agnostic, no runtime dependencies
interface RouteState {
routeId: string;
scrollY: number;
}
type NavigateHandler = (state: RouteState | null, source: "popstate" | "pageshow") => void;
function isRouteState(value: unknown): value is RouteState {
// history.state is `any`; narrow it before trusting the payload
return (
typeof value === "object" &&
value !== null &&
typeof (value as RouteState).routeId === "string"
);
}
export function createPopstateController(onNavigate: NavigateHandler): () => void {
let lastRouteId: string | null = null;
const resolveState = (raw: unknown): RouteState | null => {
if (isRouteState(raw)) return raw;
// Firefox/Safari may hand back null after a bfcache restore — recover
// the heavy payload from sessionStorage keyed by the current path.
const stored = sessionStorage.getItem(`route:${location.pathname}`);
if (!stored) return null;
try {
const parsed: unknown = JSON.parse(stored);
return isRouteState(parsed) ? parsed : null;
} catch {
return null; // never let a corrupt payload throw inside the handler
}
};
const dispatch = (raw: unknown, source: "popstate" | "pageshow") => {
const state = resolveState(raw);
// De-duplicate: Chromium + a manual pageshow can both fire for one nav.
if (state && state.routeId === lastRouteId && source === "pageshow") return;
lastRouteId = state?.routeId ?? null;
onNavigate(state, source);
};
const onPopstate = (e: PopStateEvent) => dispatch(e.state, "popstate");
const onPageshow = (e: PageTransitionEvent) => {
// e.persisted === true means the page came from the bfcache; the JS
// heap was frozen, so re-sync the UI even though popstate may not fire.
if (e.persisted) dispatch(history.state, "pageshow");
};
window.addEventListener("popstate", onPopstate);
window.addEventListener("pageshow", onPageshow);
// Return a disposer so SPA route teardown can detach cleanly.
return () => {
window.removeEventListener("popstate", onPopstate);
window.removeEventListener("pageshow", onPageshow);
};
}
When you call pushState & replaceState Usage to record a route, mirror any non-trivial payload into sessionStorage so the fallback above has something to read:
// TypeScript 5.x — framework-agnostic
export function pushRoute(routeId: string, url: string): void {
const state = { routeId, scrollY: window.scrollY };
// Keep history.state small and structured-clone-safe; park the rest
// in sessionStorage so Gecko's serialiser never silently drops it.
history.pushState(state, "", url);
sessionStorage.setItem(`route:${location.pathname}`, JSON.stringify(state));
}
Verification
Use Playwright to drive a real back-navigation in all three engines, since synthetic dispatchEvent calls bypass the bfcache and hide the exact quirks you are testing for.
// @playwright/test v1.44 — run with --project=webkit, firefox, chromium
import { test, expect } from "@playwright/test";
test("popstate restores route state on hardware-style back", async ({ page }) => {
await page.goto("/list/");
await page.click("a[href='/detail/']");
await expect(page).toHaveURL(/\/detail\//);
await page.goBack(); // triggers the engine's real back path + bfcache
await expect(page).toHaveURL(/\/list\//);
// The app should have re-rendered the list from restored state.
await expect(page.locator("[data-route='list']")).toBeVisible();
});
For a quick manual check, open DevTools, run history.pushState({routeId:'x',scrollY:0},'','/x'), then click the browser back button: a correct handler logs exactly one navigation. In Safari, confirm the same handler also runs after a back from a fully cached page by watching for the pageshow branch.
Gotchas
- Safari can return
nullfromhistory.stateafter restoring a bfcached page even when you pushed a state object — always route through asessionStorageor in-memory fallback rather than readinge.statedirectly. - Firefox throws a
DataCloneErrorifhistory.stateholds functions, DOM nodes, or class instances; keep the payload to plain JSON, which also makes Scroll Restoration Strategies easier to persist. - Chromium evicts the bfcache when an
unloadlistener is present, downgrading a back-navigation to a full reload — audit for strayunload/beforeunloadhandlers if state keeps resetting. - Rapid back/forward clicking can queue multiple
popstateevents; de-duplicate by route identifier (as above) instead of debouncing on a timer, which can drop a legitimate navigation.
FAQ
Does popstate fire on the initial page load? Not in current browsers — the spec says it should not, and Chromium, Firefox, and Safari all comply today, though old Firefox and WebKit builds once fired a spurious load-time event, which is why defensive code reads history.state on startup instead of relying on the event.
Why does my popstate handler see a null state in Safari but not in Chrome? Safari often restores a page from the bfcache without repopulating the JavaScript history.state you expect, so e.state is null; listen for pageshow with event.persisted === true and recover the payload from sessionStorage to make both engines behave the same.
How do I stop a single back-navigation from firing my handler twice? The duplicate usually comes from handling both popstate and a manual pageshow for one navigation; track the last route identifier you processed and ignore a pageshow that repeats it, rather than racing the events on a timeout.
Can I store complex objects in history.state across browsers? No — Firefox enforces the structured-clone algorithm strictly and will throw on functions, DOM nodes, or class instances, so keep history.state to plain serialisable JSON and store anything heavier in sessionStorage keyed to the route.
Does disabling the bfcache fix cross-browser popstate differences? It removes the Safari restore-timing quirk but costs you instant back-navigation and worsens performance, so prefer normalising with pageshow over forcing reloads with an unload handler.
Related
- popstate Event Handling — the parent guide to listening for and responding to back/forward navigation.
- pushState & replaceState Usage — how to write the history entries and state objects that popstate later restores.
- Scroll Restoration Strategies — persisting and restoring scroll position alongside route state across navigations.