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 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.

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.