popstate Event Handling
When a user presses the browser back button, the URL changes but your application code receives only one signal that anything happened: the popstate event. Getting this handler right is the difference between a single-page application that feels like the native browser and one that shows stale views, double-fetches data, or loses the user’s scroll position. This page walks through a production-grade popstate implementation that survives rapid back/forward bursts, keeps the rendered UI in lockstep with history.state, and announces navigation to assistive technology — without blocking the main thread.
← Back to History API & State Management
The Problem
popstate is deceptively narrow: it fires only when the active history entry changes as the result of traversal — the back/forward buttons, history.back(), history.forward(), or history.go(). It never fires for the programmatic pushState & replaceState calls that you use to record a navigation, and it never fires on the initial document load. That asymmetry is where most bugs originate: developers write the “navigate” path and the “traverse” path as two separate code branches that drift apart over time.
The failures are concrete and user-visible:
- Stale UI. If the handler does not re-derive the view from the new URL and state, the user presses back and the address bar updates while the page content stays frozen on the previous route.
- Race conditions. A user mashing the back button five times in 200 ms fires five
popstateevents. If each kicks off an async data fetch, responses can resolve out of order and the slowest one wins, rendering content for an entry the user already left. - Double work on load. A naive listener that also runs its body once at startup will fetch the initial route twice — once from your bootstrap code and once from a phantom early
popstate. - Lost scroll and viewport jank. Restoring scroll synchronously inside the handler, before content has hydrated, snaps the page to the wrong offset and triggers layout thrashing.
- Inaccessible transitions. Screen reader users get no announcement that the route changed, because nothing updated a live region.
A correct handler treats traversal as a re-render from a source of truth (the URL plus event.state), guards against overlapping async work, and defers visual side effects.
Core API & Primitives
The event itself is small. The discipline lives in how you read it.
// TypeScript 5.x — framework-agnostic, no dependencies
// The single payload property: a structured-clone of whatever you stored.
interface PopStateEvent extends Event {
readonly state: unknown;
}
// What we choose to store per history entry. Keep it small and serialisable —
// the browser clones it with the structured clone algorithm, so functions,
// DOM nodes and class instances are not allowed.
interface RouteState {
route: string; // canonical path key, e.g. "/orders/42"
scrollY: number; // captured at navigation time for restoration
token: number; // monotonically increasing nav id for race guards
}
// The two read sources you must reconcile on every traversal:
// 1. event.state — your serialised payload (may be null)
// 2. location — the authoritative URL the browser navigated to
type Reconcile = (path: string, state: RouteState | null) => void;
The reason to lean on window.location rather than event.state is robustness. The URL is the one piece of navigation data the browser guarantees to be correct after a traversal — it is what the address bar shows and what the user would copy to share the link. Your state object, by contrast, is whatever you happened to store at navigation time; it can be null, it can predate a deploy that changed your state shape, and it can be absent entirely for entries created by an anchor click rather than your router. Treating the URL as the authority and the state as an optimisation (a cache of scroll offset, a navigation token, prefetched ids) keeps the view correct even when the state is missing or malformed.
Three constraints drive the implementation:
event.stateis read-only during the event. To change the current entry’s payload you must callhistory.replaceState(); mutating the object you receive does nothing.event.stateisnullfor any entry that was created without an explicit state object (including the very first entry on a cold load), so every code path needs a null branch that falls back towindow.location.- The browser’s bfcache (back/forward cache) can restore a whole page from memory and, in some browsers, not fire
popstateat all — it firespageshowwithevent.persisted === trueinstead. The full matrix is covered in Cross-Browser popstate Event Quirks.
Step-by-Step Implementation
Prerequisite: you are already recording navigations with history.pushState() and storing a small serialisable state object on each entry; this section only covers the traversal (popstate) side.
Step 1: Seed the initial entry so traversal always has state
popstate does not fire on load, so the first entry must be given a state object up front with replaceState. This guarantees every later traversal back to the start finds a non-null event.state and the same code path runs everywhere.
// TypeScript 5.x — framework-agnostic, no dependencies
let navToken = 0;
function seedInitialEntry(): void {
// Only seed if the current entry has no state of our shape.
const existing = history.state as RouteState | null;
if (existing && typeof existing.route === 'string') return;
history.replaceState(
{ route: location.pathname, scrollY: window.scrollY, token: navToken } satisfies RouteState,
'',
location.href,
);
}
seedInitialEntry();
Step 2: Attach a single listener that re-derives from URL + state
Reconcile from location as the authority and use event.state only as a hint. Reading the URL first means a tampered or missing state object never desynchronises the view.
// TypeScript 5.x — framework-agnostic, no dependencies
declare function reconcile(path: string, state: RouteState | null): void;
function handlePopState(event: PopStateEvent): void {
const state = (event.state ?? null) as RouteState | null;
// location is the source of truth; state is supplementary metadata.
reconcile(location.pathname + location.search, state);
}
window.addEventListener('popstate', handlePopState);
Step 3: Guard async work against out-of-order resolution
Each traversal claims a fresh token. When an async load resolves, it commits to the UI only if it is still the latest token — stale responses are discarded, eliminating the back-button race.
// TypeScript 5.x — framework-agnostic, no dependencies
declare function loadRoute(path: string): Promise<unknown>;
declare function render(path: string, data: unknown): void;
async function reconcile(path: string, _state: RouteState | null): Promise<void> {
const myToken = ++navToken; // claim this traversal
const data = await loadRoute(path); // may resolve after a newer traversal
if (myToken !== navToken) return; // a later popstate superseded us — bail
render(path, data);
}
Step 4: Defer scroll and visual side effects to a frame
Restoring scroll and other layout work runs inside requestAnimationFrame, after content commits, so navigation never blocks and reads/writes do not thrash. Coalescing also collapses rapid bursts into a single visual update. Pair this with the broader Scroll Restoration Strategies for full control.
// TypeScript 5.x — framework-agnostic, no dependencies
let rafId: number | null = null;
let pendingState: RouteState | null = null;
function scheduleVisualSync(state: RouteState | null): void {
pendingState = state;
if (rafId !== null) return; // coalesce bursts into one frame
rafId = requestAnimationFrame(() => {
rafId = null;
const target = pendingState?.scrollY ?? 0;
window.scrollTo({ top: target, behavior: 'auto' });
pendingState = null;
});
}
Step 5: Announce the change and cover bfcache restorations
Update an aria-live region so screen readers hear the new route, and listen for pageshow with event.persisted to catch browsers that restore from cache without firing popstate.
// TypeScript 5.x — framework-agnostic, no dependencies
function announce(path: string): void {
const region = document.getElementById('route-announcer');
if (region) region.textContent = `Navigated to ${path}`; // aria-live="polite"
}
window.addEventListener('pageshow', (event: PageTransitionEvent) => {
// bfcache restore: reconcile manually because popstate may not fire.
if (event.persisted) {
reconcile(location.pathname + location.search, history.state as RouteState | null);
}
});
Inside a component framework the same listener is owned by the lifecycle. In React, attach in useEffect and return the cleanup so the listener is removed on unmount; in Vue, attach in onMounted and detach in onUnmounted (or simply watch the router’s reactive route). The wiring differs across the React Router implementation, the Next.js App Router, and Vue Router configuration, but the four-step contract — seed, reconcile from URL, token-guard async, defer visuals — is identical.
// react v18 — framework-agnostic listener inside a hook
import { useEffect } from 'react';
export function usePopStateSync(reconcile: (path: string) => void): void {
useEffect(() => {
const onPop = () => reconcile(location.pathname + location.search);
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop); // no leak
}, [reconcile]);
}
Verification & Testing
Drive real back/forward traversal in a browser; unit tests with synthetic events miss the bfcache and ordering behaviour that matters most. The Playwright check below proves the view follows the URL across multiple back presses.
// @playwright/test v1.4x
import { test, expect } from '@playwright/test';
test('popstate keeps the view in sync across back/forward', async ({ page }) => {
await page.goto('/orders');
await page.getByRole('link', { name: 'Order 42' }).click();
await expect(page).toHaveURL(/\/orders\/42/);
await expect(page.getByRole('heading')).toHaveText('Order 42');
await page.goBack(); // fires popstate
await expect(page).toHaveURL(/\/orders$/);
await expect(page.getByRole('heading')).toHaveText('Orders');
await page.goForward(); // fires popstate again
await expect(page.getByRole('heading')).toHaveText('Order 42');
// Race guard: rapid double-back must land on the correct, single view.
await page.getByRole('link', { name: 'Order 7' }).click();
await Promise.all([page.goBack(), page.goBack()]);
await expect(page).toHaveURL(/\/orders$/);
});
For a quick manual check, paste this into the DevTools console and traverse:
// TypeScript 5.x — DevTools console probe
window.addEventListener('popstate', (e: PopStateEvent) => {
console.table({ path: location.pathname, hasState: e.state !== null, state: e.state });
});
Performance Tuning
The handler runs synchronously on the main thread, so anything expensive inside it delays the visual response to the back button. Concrete, measurable optimisations:
- Keep the handler under one frame. Open the Performance panel, record a back/forward burst, and confirm each
popstatecallback finishes inside the ~16 ms budget for 60 fps. Move parsing or diffing out of the synchronous path. - Coalesce bursts. The
requestAnimationFramegate in Step 4 collapses N rapid traversals into one layout pass, so a user holding the back button costs one reflow instead of N. - Cap stored state size. The structured clone of
event.stateis materialised on every traversal. Keep payloads to a few kilobytes; store an id and rehydrate from cache rather than embedding large objects, and evict entries you no longer need so long-lived sessions do not accumulate detached state. - Avoid synchronous layout reads in the callback. Batch any
getBoundingClientRect()reads with writes in the samerAFto prevent forced reflow. - Profile the back-button, not just first paint. Lab tooling usually measures cold load, but the cost users feel on traversal is the time between the back press and the reconciled view. Mark the start of your handler with
performance.mark('popstate-start')and the commit of the new view with a second mark, then measure between them; this surfaces regressions that a Lighthouse run on the entry URL will never catch. - Set
history.scrollRestorationexplicitly. Leaving it at the default'auto'lets the browser race your own scroll logic. Set it to'manual'once at startup so the offsets you restore in Step 4 are the only ones applied, removing a class of intermittent jump-on-back bugs.
Gotchas & Failure Modes
- Assuming
popstatefires on load. It does not. Seed the first entry withreplaceState(Step 1) and run your initial render from bootstrap code, not from the listener. - Trusting
event.stateover the URL. State can benullor stale; the URL is authoritative. Always reconcile fromlocationfirst. - Mutating
event.state. It is read-only during the event — changes are silently discarded. Usehistory.replaceState()to update the current entry, as covered in Preventing Duplicate History Entries with replaceState. - No race guard. Without the monotonic token (Step 3), out-of-order async resolutions render content for an entry the user already left.
- Forgetting bfcache. Some browsers restore from cache and skip
popstate; without thepageshow/persistedfallback the page shows pre-cache state. - Leaking listeners. Re-attaching on every mount without removing on unmount stacks duplicate handlers and grows memory unbounded in long SPA sessions.
Go Deeper
- Cross-Browser popstate Event Quirks — the per-browser differences in bfcache, initial-load firing, and state cloning that the contract above abstracts over, with vendor-specific workarounds.
Related
- History API & State Management — the parent area covering the full set of navigation, state, and scroll primitives.
- pushState & replaceState Usage — the navigate side of the contract that records the entries
popstatelater replays. - Scroll Restoration Strategies — how to restore viewport offsets on traversal without blocking or thrashing.
- Cross-Browser popstate Event Quirks — vendor-specific behaviour and the edge cases that break naive handlers.