Manual Scroll Restoration for SPAs

After reading this you will be able to capture, persist, and restore the exact viewport position per route in a single-page application so that back/forward navigation lands the user where they left off — even when route content mounts asynchronously.

← Back to Scroll Restoration Strategies

Prerequisites

Core Concept

In a traditional document navigation, the browser snapshots scroll coordinates for each history entry and replays them on back/forward. A single-page application breaks this contract: pushState and replaceState mutate the URL without a document load, and the new view’s DOM is often replaced or hydrated after the popstate event has already fired. The browser’s 'auto' restoration mode tries to restore against a DOM that does not yet have its final height, so it clamps the scroll to 0 and the user is dumped at the top.

Manual scroll restoration means setting history.scrollRestoration = 'manual' to opt out of the native behaviour, then taking on three responsibilities yourself: save the position keyed by a stable route identifier before you leave a view, clear it (or scroll to top) on a forward push, and restore it on popstate once the destination’s content has reached a stable layout. The key per entry should be the History API state, not just location.pathname, so revisiting the same path through different history entries keeps independent positions.

Implementation

// TypeScript 5.x — framework-agnostic, no router dependency
// Pairs with any router that uses history.pushState / replaceState.

interface ScrollPosition {
  x: number;
  y: number;
}

const STORAGE_KEY = "spa:scroll-positions";

// Opt out of the browser's native restoration BEFORE any routing runs,
// otherwise 'auto' races your code on the first popstate.
if ("scrollRestoration" in history) {
  history.scrollRestoration = "manual";
}

// Each history entry gets a unique key stored in history.state so that the
// same pathname visited twice keeps two independent scroll positions.
function entryKey(): string {
  const state = history.state as { __scrollKey?: string } | null;
  if (state?.__scrollKey) return state.__scrollKey;
  const key = `k_${Date.now()}_${Math.random().toString(36).slice(2)}`;
  // Stamp the key without changing the URL (replaceState keeps the entry).
  history.replaceState({ ...(state ?? {}), __scrollKey: key }, "");
  return key;
}

function readAll(): Record<string, ScrollPosition> {
  try {
    return JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? "{}");
  } catch {
    return {}; // Quota or private-mode failure — degrade silently.
  }
}

function savePosition(): void {
  const all = readAll();
  all[entryKey()] = { x: window.scrollX, y: window.scrollY };
  sessionStorage.setItem(STORAGE_KEY, JSON.stringify(all));
}

// Restore is deferred until the destination DOM is tall enough to hold the
// target offset; otherwise scrollTo silently clamps to the page bottom/0.
function restorePosition(): void {
  const pos = readAll()[entryKey()];
  if (!pos) {
    window.scrollTo(0, 0); // No record — treat as a fresh forward push.
    return;
  }
  let attempts = 0;
  const tryScroll = (): void => {
    const maxY = document.documentElement.scrollHeight - window.innerHeight;
    if (pos.y <= maxY || attempts >= 20) {
      window.scrollTo(pos.x, pos.y);
      return;
    }
    attempts += 1; // Content still mounting — wait one frame and retry.
    requestAnimationFrame(tryScroll);
  };
  requestAnimationFrame(tryScroll);
}

// Save on the way out of the current entry (programmatic navigations should
// call savePosition() themselves just before pushState).
window.addEventListener("popstate", () => {
  restorePosition();
});
window.addEventListener("pagehide", savePosition); // Covers hard refresh / close.

export { savePosition, restorePosition, entryKey };

To respect motion preferences and keyboard focus during the restore, wrap the final scrollTo so that animated scrolling is skipped for users who ask for reduced motion:

// TypeScript 5.x — accessibility-aware restore helper
function scrollToWithA11y(pos: ScrollPosition): void {
  const reduceMotion = window.matchMedia(
    "(prefers-reduced-motion: reduce)",
  ).matches;
  window.scrollTo({
    left: pos.x,
    top: pos.y,
    behavior: reduceMotion ? "auto" : "smooth",
  });
  // Re-assert focus so keyboard users are not stranded at the document root.
  const target = document.querySelector<HTMLElement>("[data-focus-on-restore]");
  target?.focus({ preventScroll: true });
}

Verification

Confirm the behaviour with a Playwright assertion that scrolls, navigates forward, then goes back and checks the restored offset is within a small tolerance:

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

test("restores scroll on back navigation", async ({ page }) => {
  await page.goto("/list/");
  await page.evaluate(() => window.scrollTo(0, 1200));
  const before = await page.evaluate(() => window.scrollY);

  await page.click("a[data-route='/detail/']"); // pushState navigation
  await page.waitForFunction(() => window.scrollY === 0);

  await page.goBack(); // fires popstate -> restorePosition
  await page.waitForFunction(
    (y) => Math.abs(window.scrollY - y) < 5,
    before,
  );
  expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(1000);
});

For a quick manual check, open DevTools, run history.scrollRestoration and confirm it returns "manual", scroll down, navigate, then use the browser back button and watch the page snap back to the saved offset rather than the top.

Gotchas

  • scrollTo clamps before content mounts. If you restore on a synchronous popstate handler the document is still short, so the offset is silently truncated to the current page height. The frame-by-frame retry against scrollHeight above is what makes lazy and code-split routes work.
  • Keying by pathname alone bleeds positions. Two history entries for /feed/ (visited twice) share one record and overwrite each other. Stamp a unique key into history.state so each entry is independent — this also coexists cleanly with preventing duplicate history entries with replaceState.
  • Hash anchors fight your restore. When the URL carries a #section fragment the browser performs native anchor scrolling that can override scrollTo. Detect a non-empty location.hash and skip manual restoration for that navigation.
  • beforeunload is unreliable on mobile. Tab discards and the bfcache often skip it; listen for pagehide instead to capture the final position before a hard refresh or close.

How to Implement Manual Scroll Restoration

  1. Disable native restoration. Set history.scrollRestoration = 'manual' at application start, before any routing code runs, so the browser stops racing your handler.
  2. Stamp a unique key per entry. Write a __scrollKey into history.state with replaceState so each history entry maps to its own saved position.
  3. Save on exit. Persist { x, y } to sessionStorage under the entry key just before a programmatic push and on the pagehide event.
  4. Restore on popstate. On back/forward, read the saved position and retry scrollTo across animation frames until document.scrollHeight is tall enough to hold the offset.
  5. Honour accessibility. Skip smooth scrolling when prefers-reduced-motion is set and re-assert keyboard focus after the scroll completes.

FAQ

Should I use sessionStorage or in-memory state for scroll coordinates? Use sessionStorage so positions survive a hard refresh within the same tab and are scoped to that tab, then optionally mirror the current entry in memory for the fastest read path; in-memory-only state loses everything on reload, which is exactly when users notice a broken back button.

Why does window.scrollTo do nothing on a lazy-loaded route? Because the target offset exceeds the current document.scrollHeight while the route is still mounting, so the browser clamps the value. Defer the call across requestAnimationFrame ticks until the document is tall enough, as shown in the implementation, rather than scrolling on the synchronous popstate handler.

Does popstate fire when I call pushState? No — popstate only fires on back/forward navigation or history.go(), never on programmatic pushState or replaceState, so you must call your save routine yourself immediately before any programmatic navigation.

Does manual scroll restoration hurt SEO? No. Crawlers ignore client-side scroll position and render content from the top, and the History API state only affects in-session client navigation, so applying scrollRestoration = 'manual' has no effect on how a page is indexed.