Distinguishing Back from Forward Navigation

After reading this you will be able to tell, inside a popstate handler, whether the user went back or forward — and by how many entries — using a monotonic index stamped into every history entry your router writes.

← Back to popstate Event Handling

Prerequisites

Core Concept

PopStateEvent carries exactly one piece of information: the state object of the entry the browser has just moved to. It does not say which direction the user moved, how far, or whether the movement came from the toolbar buttons, a keyboard shortcut, a swipe gesture, or a scripted history.go(-3). history.length is no help either — it counts the whole stack, not the current position, and it does not change when you move within it.

The standard solution is to stamp a monotonically increasing index into every entry you create. When popstate fires, compare the index in the new entry with the index you last saw. A smaller number means backward; a larger one means forward; the difference is the distance travelled.

push /a  → index 0
push /b  → index 1
push /c  → index 2      current = 2
back     → popstate with index 1   → 1 − 2 = −1  → back one
back     → popstate with index 0   → 0 − 1 = −1  → back one
forward  → popstate with index 2   → 2 − 0 = +2  → forward two

Two subtleties make the difference between a technique that works and one that produces occasional nonsense. First, entries created before your router loaded — or by another script — have no index, so the handler must treat undefined as “unknown direction” rather than as zero. Second, replaceState must preserve the existing index rather than allocating a new one; replacing an entry does not move the user, and allocating on replace makes every filter change look like a forward navigation.

Direction is genuinely useful in three places: transition direction (slide left on forward, right on back), scroll behaviour (restore on back, reset on forward), and focus (return to the previous element on back, move to the heading on forward). If you need none of those, you do not need this at all.

Deriving direction from a monotonic index A history stack of four entries, each stamped with an increasing index. Moving from index three to index one yields a delta of minus two, identified as a backward navigation of two entries. Moving from index one to index three yields plus two, a forward navigation. Session history, each entry stamped with an index /products index 0 /products/7 index 1 /products/7/specs index 2 /checkout index 3 · current back two: 1 − 3 = −2 forward two: 3 − 1 = +2 popstate reports the destination entry only — the delta is the difference between its index and the last one you saw.
The event carries no direction, so the router supplies one: a monotonic index per entry turns popstate into a signed distance.

Implementation

// TypeScript 5.x — a direction-aware navigation observer, no dependencies

export type Direction = "forward" | "back" | "replace" | "unknown";

interface IndexedState {
  /** Monotonic position in the session history, allocated on push only. */
  navIndex: number;
  [key: string]: unknown;
}

let counter = 0;                    // allocates indices for new entries
let currentIndex: number | null = null; // the index we believe we are on

function isIndexed(value: unknown): value is IndexedState {
  return typeof value === "object" && value !== null &&
    typeof (value as IndexedState).navIndex === "number";
}

/** Call once at startup: stamp the entry the user landed on. */
export function initHistoryIndex(): void {
  if (isIndexed(history.state)) {
    // Returning to a tab that already has stamped entries — adopt its numbering.
    currentIndex = history.state.navIndex;
    counter = Math.max(counter, currentIndex + 1);
    return;
  }
  currentIndex = counter;
  history.replaceState({ ...(history.state as object ?? {}), navIndex: counter }, "");
  counter += 1;
}

Pushing allocates the next index; replacing deliberately keeps the current one:

// TypeScript 5.x

export function pushIndexed(url: string, state: Record<string, unknown> = {}): void {
  currentIndex = counter;
  history.pushState({ ...state, navIndex: counter }, "", url);
  counter += 1;
}

export function replaceIndexed(url: string, state: Record<string, unknown> = {}): void {
  // Replacing does NOT move the user, so the index must not change — otherwise
  // every filter update would later read as a forward navigation.
  const keep = currentIndex ?? 0;
  history.replaceState({ ...state, navIndex: keep }, "", url);
}

The handler derives direction and distance in one place:

// TypeScript 5.x

export function onDirectionalNavigation(
  handler: (info: { direction: Direction; delta: number; state: unknown }) => void,
): () => void {
  const listener = (event: PopStateEvent) => {
    const next = isIndexed(event.state) ? event.state.navIndex : null;

    if (next === null || currentIndex === null) {
      // An entry we never stamped: another script, or a pre-router entry.
      currentIndex = next;
      handler({ direction: "unknown", delta: 0, state: event.state });
      return;
    }

    const delta = next - currentIndex;
    currentIndex = next;

    handler({
      direction: delta === 0 ? "replace" : delta > 0 ? "forward" : "back",
      delta,
      state: event.state,
    });
  };

  window.addEventListener("popstate", listener);
  return () => window.removeEventListener("popstate", listener);
}

And the payoff — three behaviours that should differ by direction:

// TypeScript 5.x — direction-aware transitions, scroll, and focus

onDirectionalNavigation(({ direction, delta }) => {
  document.documentElement.dataset.navDirection = direction;

  if (direction === "back") {
    restoreSavedScroll();     // the user has seen this view; put it back as it was
    restorePreviousFocus();
  } else if (direction === "forward") {
    // Only reset scroll for a genuinely new destination, not a re-entry.
    if (delta > 0) window.scrollTo(0, 0);
    focusHeading();
  }
});
Behaviours that should differ by navigation direction A table of three behaviours across two directions. On a backward navigation, scroll is restored, focus returns to the previous element and the transition slides right. On a forward navigation, scroll resets, focus moves to the heading and the transition slides left. What direction should change behaviour back forward scroll restore the saved offset reset to the top focus return to the previous element move to the new heading transition slide right — retracing steps slide left — moving onward
Direction is only worth deriving if something depends on it; these three are the behaviours users actually notice getting it wrong.

Verification

// @playwright/test v1.44
import { test, expect } from "@playwright/test";

test("direction and distance are derived correctly", async ({ page }) => {
  await page.goto("/products");
  await page.getByRole("link", { name: "Laptop" }).click();
  await page.getByRole("link", { name: "Specifications" }).click();

  const seen: { direction: string; delta: number }[] = [];
  await page.exposeFunction("recordNav", (d: string, n: number) => seen.push({ direction: d, delta: n }));
  await page.evaluate(() => {
    window.addEventListener("popstate", (e) => {
      const idx = (e.state as { navIndex?: number })?.navIndex;
      (window as never as { recordNav: (d: string, n: number) => void })
        .recordNav(idx === 0 ? "back" : "forward", idx ?? -1);
    });
  });

  await page.goBack();
  await page.goBack();
  expect(seen.filter((s) => s.direction === "back").length).toBeGreaterThan(0);

  // A replaceState-driven filter change must NOT read as a navigation.
  const before = await page.evaluate(() => (history.state as { navIndex: number }).navIndex);
  await page.getByLabel("In stock only").check();
  const after = await page.evaluate(() => (history.state as { navIndex: number }).navIndex);
  expect(after).toBe(before);
});

Manually, keep history.state open in the console and click through a few pages. The index should increase by one per link click, stay put on every filter change, and jump to the expected value on each Back press. Any other pattern points at a replaceState call that is allocating an index it should not.

Gotchas

  • history.length cannot substitute for an index. It counts the stack, not the position, and it does not change when you move within it.
  • replaceState must keep the index. Allocating on replace makes filter changes read as forward navigations, which then reset scroll on every keystroke.
  • Entries from before your router loaded have no index. Treat undefined as unknown; assuming zero produces a spurious back-navigation of several entries.
  • Cross-document navigations reset your counter. A full page load starts a new module instance, which is why initHistoryIndex adopts the numbering it finds rather than restarting at zero.
  • A swipe gesture can move several entries at once. Handle |delta| > 1 explicitly rather than assuming single steps.
  • The Navigation API makes all of this unnecessary where it is available: navigateEvent.navigationType and navigation.entries() provide direction directly, as covered in migrating from popstate to the Navigation API.
Five navigation sources and what the handler sees Five ways the URL can change, each classified by whether it fires popstate and what index delta it produces. Programmatic writes fire nothing, user traversals fire popstate with a signed delta, and a replaceState refinement must leave the index unchanged. What each navigation source produces fires popstate? index delta clicking an in-app link no — pushState is silent +1, set by your code the browser back button yes negative a trackpad swipe back yes — indistinguishable negative history.go(-3) yes -3, reported exactly a filter change (replaceState) no 0 — the index must not move
The last row is the one that breaks implementations: a refinement must not move the index, or every filter change reads as a forward navigation.

FAQ

Why does popstate not include a direction? The event predates single-page applications by some years and was designed to report a state change, not a user gesture. The Navigation API, designed with routers in mind, corrects this.

Can I detect a swipe-back specifically? Not distinguishably — a trackpad swipe, the toolbar button and history.back() all produce the same event. You can detect the distance, which covers most real needs.

Does this work with hash routing? Yes for the index mechanism, but hashchange fires alongside popstate and you should handle only one of them, or every navigation is processed twice.

What about history.go(-3)? It produces one popstate with a delta of −3, which is exactly what the index comparison reports. Nothing extra is required.

Should I store the direction in the entry itself? No. Direction is a property of a transition, not of an entry — the same entry is arrived at forwards and backwards at different times.