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
scrollToclamps before content mounts. If you restore on a synchronouspopstatehandler the document is still short, so the offset is silently truncated to the current page height. The frame-by-frame retry againstscrollHeightabove is what makes lazy and code-split routes work.- Keying by
pathnamealone bleeds positions. Two history entries for/feed/(visited twice) share one record and overwrite each other. Stamp a unique key intohistory.stateso 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
#sectionfragment the browser performs native anchor scrolling that can overridescrollTo. Detect a non-emptylocation.hashand skip manual restoration for that navigation. beforeunloadis unreliable on mobile. Tab discards and the bfcache often skip it; listen forpagehideinstead to capture the final position before a hard refresh or close.
How to Implement Manual Scroll Restoration
- Disable native restoration. Set
history.scrollRestoration = 'manual'at application start, before any routing code runs, so the browser stops racing your handler. - Stamp a unique key per entry. Write a
__scrollKeyintohistory.statewithreplaceStateso each history entry maps to its own saved position. - Save on exit. Persist
{ x, y }tosessionStorageunder the entry key just before a programmatic push and on thepagehideevent. - Restore on popstate. On back/forward, read the saved position and retry
scrollToacross animation frames untildocument.scrollHeightis tall enough to hold the offset. - Honour accessibility. Skip smooth scrolling when
prefers-reduced-motionis 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.
Related
- Scroll Restoration Strategies — the parent overview of automatic and manual approaches to preserving viewport position.
- popstate Event Handling — how to detect back/forward navigation, the event that triggers restoration.
- History API & State Management — the broader set of techniques for driving navigation and per-entry state.