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.

Five cross-browser popstate differences Five behavioural differences between browsers around the popstate event: initial-load firing, hashchange overlap, null state on the first entry, ordering relative to DOM readiness, and back-forward cache restores. Behaviour differences worth coding around difference defensive response popstate on initial load some browsers fire it, others do not never treat it as user intent hashchange alongside popstate both fire for a fragment change handle one and ignore the other event.state after a reload restored, but may be null on the first entry branch on null explicitly ordering with the DOM ready event not guaranteed do not depend on it bfcache restore pageshow fires, popstate may not listen for pageshow too
None of these are bugs in the specification sense — they are places it leaves room, which is why the handler must be defensive.

Gotchas

  • Safari can return null from history.state after restoring a bfcached page even when you pushed a state object — always route through a sessionStorage or in-memory fallback rather than reading e.state directly.
  • Firefox throws a DataCloneError if history.state holds 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 unload listener is present, downgrading a back-navigation to a full reload — audit for stray unload/beforeunload handlers if state keeps resetting.
  • Rapid back/forward clicking can queue multiple popstate events; de-duplicate by route identifier (as above) instead of debouncing on a timer, which can drop a legitimate navigation.
Four deliberate tests for popstate behaviour Four tests that expose the cross-browser differences: reloading then going back, a fragment navigation followed by back, a back-forward cache restore, and traversing two entries at once. Testing popstate behaviour deliberately test catches reload on a deep route, then press Back the initial-event difference a spurious first render navigate to a fragment, then Back hashchange plus popstate double handling restore a page from bfcache pageshow without popstate stale view after Back history.go(-2) one event, two entries index arithmetic errors
None of these occur during ordinary click-through testing, which is why they survive into production.

It is worth being clear about why these differences exist, because it changes how you respond to them. Almost none of them are bugs. The specification deliberately leaves the initial-load case to the browser, says nothing about ordering relative to other load events, and describes the back-forward cache as a separate mechanism with its own signal. Browsers made different reasonable choices inside that latitude, and those choices are now stable enough that they will not converge.

The consequence is that defensive handling is permanent rather than temporary. There is no version to wait for. A handler written to tolerate a spurious initial event, a null state, an unpredictable ordering and a bfcache restore will keep working indefinitely — and one written against a single browser’s behaviour will keep failing on the others indefinitely too.

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.

The four stages of a defensive popstate handler A four-stage handler: guard against the spurious initial event by comparing URLs, read the possibly-null state, re-resolve the route from the URL rather than the state, then restore scroll and focus. A defensive popstate handler 1 · guard ignore the initial event compare against the last URL 2 · read event.state, may be null never assume a shape 3 · resolve re-run the matcher the URL is the truth 4 · restore scroll and focus in that order, preventScroll
Resolving from the URL rather than from the state object is what makes the handler survive every difference above.