Browser back and forward behavior is one of the easiest SPA features to get almost right and one of the hardest to test well. The trap is over-asserting the browser history itself, then adding waits until the test seems stable. That usually produces brittle checks that fail when a router re-renders slightly differently, when scroll restoration is asynchronous, or when the app preserves state more intelligently than the test expects.

The better strategy is to verify the user-visible contract of navigation. In a single-page app, that usually means checking the URL, the rendered route, preserved form state, scroll position when relevant, and any blocking behavior for modals or unsaved changes. The browser history is the mechanism, not always the assertion target.

Short version: test the outcome the user can observe, then only inspect history-related details when the product explicitly depends on them.

What makes SPA history testing flaky?

Single-page apps often change views without full page loads. Framework routers use the browser History API, usually pushState() and replaceState(), to update the URL and state without navigating away. The browser then handles Back and Forward through popstate events and history entries.

That sounds deterministic, but test flakiness usually comes from one of these sources:

  • the router updates the URL before the DOM finishes rendering
  • a data fetch completes after the route change and reorders the UI
  • scroll restoration is deferred until after layout
  • a modal or unsaved-changes prompt intercepts navigation
  • the app stores route state separately from browser history state
  • tests assert history.length or exact stack shape, which can vary by setup

For browser automation, the lesson is simple: avoid asserting internals unless the feature depends on them. The History API defines how entries are added and traversed, but your test should usually prove that the app responds correctly to traversal, not that the stack contains a particular number of entries.

What to assert first, and what to leave alone

Use this order of assertions when you test browser back and forward navigation in single-page apps:

  1. URL changes when the route should change.
  2. DOM state matches the destination view.
  3. Scroll position is preserved or reset when the product requires it.
  4. Form data remains intact when the user returns to an unfinished step.
  5. Blocking dialogs or prompts appear when navigation should be interrupted.
  6. History internals only if the application specifically exposes or depends on them.

URL checks

URL checks are stable because they reflect the public contract of the route. They are especially useful for list/detail flows, wizard steps, and filter state encoded in query parameters.

A good URL assertion answers a concrete question: did the app go back to the expected route, with the expected query string or hash fragment?

DOM checks

DOM checks confirm that the route actually rendered the right view. This matters because many routers can update the address bar before async data or lazy-loaded components finish rendering.

Scroll checks

Scroll restoration is easy to forget and hard to verify by eye in CI. If the product promises preserved scroll position, assert it directly. If it does not, avoid making scroll stability part of the test unless you need it for a user-facing reason.

Form-state checks

Back and forward navigation is often used in multi-step forms and checkout flows. The important question is whether the app preserves the correct data when the user returns. In that case, the form value is more meaningful than the browser history stack.

A practical testing pattern

The most reliable pattern is a route-level round trip:

  1. start on a known route
  2. click through to a second route
  3. verify the second route rendered
  4. use Back
  5. verify the first route and its state
  6. use Forward
  7. verify the second route again

This catches both navigation direction and state restoration without depending on timing guesses.

Playwright example

import { test, expect } from '@playwright/test';
test('back and forward restore SPA route state', async ({ page }) => {
  await page.goto('https://example.com/products');
  await page.getByRole('link', { name: 'Product 42' }).click();

  await expect(page).toHaveURL(/\/products\/42$/);
  await expect(page.getByRole('heading', { name: 'Product 42' })).toBeVisible();

  await page.goBack();
  await expect(page).toHaveURL(/\/products$/);
  await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();

  await page.goForward();
  await expect(page).toHaveURL(/\/products\/42$/);
  await expect(page.getByRole('heading', { name: 'Product 42' })).toBeVisible();
});

This works because the assertions are tied to the route contract. Notice what is missing: no sleep, no history-length assertion, no manual polling for popstate.

When to assert the History API directly

There are cases where direct history inspection is useful, but they are narrower than many teams expect.

Use it only when the feature depends on route state being stored in history.state, for example:

  • restoring a selected tab or filter from history state
  • preserving wizard progress across back/forward traversal
  • supporting deep links where the state is encoded in the current history entry

If you need that level of verification, check the value that matters, not the full browser stack. A small state assertion is usually enough.

const state = await page.evaluate(() => history.state);
expect(state).toMatchObject({ tab: 'billing' });

Do not turn this into a full-stack accounting test. The browser is free to manage session history details in ways that are not meaningful to the user-facing contract.

Handling modals and unsaved-changes prompts

Back button automation becomes much trickier when a route can be intercepted.

Two cases matter most:

1) In-app modals or drawers

If a modal is its own route, Back should close it or return to the parent route depending on product design. Test both directions explicitly:

  • open modal
  • assert modal route or modal content
  • press Back
  • assert modal closed and parent view visible
  • press Forward
  • assert modal reopened, if that is the intended behavior

If the modal is not a route, then browser Back should usually ignore it and navigate the page instead. In that case, asserting modal state on Back is a bug in the test, not the app.

2) Unsaved changes prompts

When the app blocks leaving a page with unsaved changes, your test should verify the block and the recovery path.

For browser-level dialogs, you need to distinguish between native prompts and custom in-app confirmation UIs. Automation frameworks handle them differently, and the app design may vary by browser.

For a custom confirmation dialog, assert:

  • the warning appears after Back is attempted
  • the current route stays the same if the user cancels
  • navigation proceeds if the user confirms

For native dialogs, use the framework’s dialog handling instead of trying to locate DOM elements that do not exist.

How to avoid brittle timeouts

Timeouts usually hide a missing synchronization point. Instead of waiting for an arbitrary number of milliseconds, wait for the effect of navigation.

Good synchronization targets include:

  • a route heading
  • a specific form field
  • a URL pattern
  • a network response that the destination view depends on
  • the disappearance of the previous route’s root element

If the route depends on fetches, wait for the data that the UI needs, not for a fixed delay. The official guidance in browser automation tools usually favors state-based waits over manual sleeps for this reason.

Example with route-sensitive waiting

await Promise.all([
  page.waitForURL(/\/checkout\/shipping$/),
  page.getByRole('link', { name: 'Shipping' }).click(),
]);
await expect(page.getByRole('heading', { name: 'Shipping' })).toBeVisible();

The Promise.all pattern matters when the click triggers navigation immediately. It reduces races between the click and the wait.

Distinguishing app bugs from router quirks

Not every navigation oddity is a product bug. Some failures come from how the router is configured.

Symptoms that usually point to app bugs

  • Back returns to the wrong route
  • Forward restores the wrong form data
  • scroll position resets when the product says it should persist
  • pressing Back closes an overlay but leaves the route unchanged when the overlay is route-backed

Symptoms that may point to routing configuration

  • replaceState() is used where pushState() was expected, so Back skips a step
  • route transitions change URL fragments but not canonical paths
  • nested routes remount components and clear local state
  • a framework updates the URL before data loads, creating a visible flash or duplicate render

If a test fails, reproduce the sequence manually in the browser with devtools open and the network panel visible. Then inspect whether the router is emitting pushState, replaceState, or a popstate response, and whether the UI state is derived from URL, local component state, or a store.

That distinction matters because a failing test may require either product code or route configuration, not a test rewrite.

A compact decision table

What changed on Back/Forward? Primary assertion Avoid as first check
Route path or query string URL history.length
Visible screen or page section DOM text or role-based element Arbitrary sleep
Returned form state Field values Exact history stack shape
Modal-backed route Modal visibility plus URL Generic page reload assertion
Scroll-sensitive view Scroll position Fixed millisecond timeout
Leave guard Dialog or blocker behavior Direct DOM lookup for native prompt

What to do in CI

Keep these tests small and focused. A single test should usually cover one user journey, not every possible history edge case.

A good CI shape is:

  • one smoke test for back and forward on a critical route pair
  • one test for a form or wizard that must preserve state
  • one test for an intercepting modal or unsaved-changes guard

If the app uses different routers across products or shells, run the tests against each route system that matters. A React Router flow, a Vue Router flow, and an Angular Router flow can all satisfy the same user contract while still exercising different implementation details.

Who should skip low-level history assertions?

Skip deep history.state and stack-shape checks if any of these are true:

  • the user cannot observe the state directly
  • the route is only an implementation detail of a larger flow
  • the router is known to reshape entries across browser versions
  • the test’s real requirement is URL, rendering, or data preservation

In those cases, testing the observable result gives you more signal and less maintenance.

A simple rule of thumb

If the product requirement can be phrased as “when I press Back, I should see X again,” assert X. If the requirement is “the app must store this exact value in browser history,” then inspect the state directly. Everything else belongs in the middle, where route, DOM, and form-state assertions are usually enough.

That approach makes back-button automation much less flaky, and it keeps your tests aligned with the way users actually experience SPA navigation.

FAQ

Should I assert history.length in SPA tests?

Usually no. history.length is rarely the user-facing contract, and it can be affected by test setup or previous navigation. Prefer URL and DOM assertions.

How do I test Back and Forward without fixed waits?

Wait for the route effect, such as a visible heading, URL change, or loaded form field. Use framework waits like waitForURL plus a visible-state assertion.

How do I verify scroll restoration in an SPA?

Capture the scroll position before navigation, navigate away, then go back and assert the relevant scroll offset or visible anchor. Only do this when scroll preservation is part of the requirement.

What is the difference between pushState and replaceState in testing?

pushState() creates a new history entry, which Back can traverse to. replaceState() updates the current entry without adding a new one. If Back is skipping a step, this distinction is one of the first things to inspect.

How do I test unsaved-changes prompts?

Attempt Back, assert that navigation is blocked, then verify both cancel and confirm paths. Use native dialog handling if the browser prompt is native, and DOM assertions if the app uses a custom modal.