Scroll Restoration Strategies
When a single-page application intercepts navigation, the browser stops being the authority on where the viewport should sit. Click a link, render a new view, then press back — and the page lands at the top instead of the product you were reading, or worse, snaps to a half-measured position before images finish loading. This page works through the strategies for taking that responsibility back: detecting native support, storing scroll coordinates against history entries, deferring restoration until layout settles, and persisting positions across full reloads. The goal is a transition that returns the user to the exact pixel they left, without introducing layout shift or blocking the main thread.
← Back to History API & State Management
The Problem
Browsers ship with history.scrollRestoration set to 'auto', and for a server-rendered multi-page site that is enough — every navigation is a real document load, so the engine snapshots and restores scroll offsets for free. A client-side router breaks that contract. It cancels the default navigation, swaps the DOM in place, and updates the URL through the browser’s History API, so the engine never sees a navigation it can attribute a scroll position to. The result depends on the browser and the framework, but the common failure is a back navigation that leaves the user stranded at scrollY = 0.
The naive fix — recording window.scrollY on route change and calling window.scrollTo on the way back — fails for three reasons. First, timing: the new view’s DOM is not laid out at the moment the router fires its transition, so a scroll to position 1800 hits a document that is only 600 pixels tall and silently clamps to the bottom. Second, async content: lazy images, web-font swaps, and data fetches change document height after restoration runs, so the saved offset now points somewhere else. Third, correctness of source-of-truth: a forward navigation should land at the top, a back navigation should restore — and popstate is the only signal that reliably distinguishes them, which is why coordination with popstate Event Handling is not optional. Get any of these wrong and the symptom shows up in metrics: forced synchronous layout from an early scrollTo, and cumulative layout shift when content reflows under a restored viewport.
Core API & Primitives
Four browser primitives carry the whole implementation. None of them is a framework feature; they are platform APIs you can wire into any router.
// TypeScript 5.x — framework-agnostic; lib.dom.d.ts
// 1. The mode switch. 'auto' lets the browser try; 'manual' hands you control.
interface History {
scrollRestoration: 'auto' | 'manual';
}
// 2. Synchronous scroll write. behavior 'instant' avoids smooth animation
// during restoration (a slow scroll reads as a glitch on back/forward).
declare function scrollTo(options: ScrollToOptions): void;
interface ScrollToOptions {
top?: number;
left?: number;
behavior?: 'auto' | 'instant' | 'smooth';
}
// 3. The serialisable home for per-entry data. Coordinates stored here travel
// with the history entry and arrive back on PopStateEvent.state.
interface ScrollState {
scrollY: number;
scrollX: number;
}
// 4. Readiness signals — use one to defer the scroll until layout exists.
// requestAnimationFrame: fires before the next paint.
// document.fonts.ready: resolves when web fonts have loaded.
// ResizeObserver / IntersectionObserver: react to post-render height changes.
The decision that governs everything else is where the saved coordinate lives. Embedding it in history.state via pushState and replaceState ties the position to the exact history entry, so it is automatically scoped, automatically discarded when the entry is, and delivered straight back through popstate. That is the approach the steps below take; for the mechanics of writing state without polluting the stack, see pushState & replaceState Usage. When you need positions to survive a full reload — a tab refresh or a return visit — you fall back to a storage layer, covered in Performance Tuning.
Step-by-Step Implementation
Prerequisite: a client-side router that you can hook on navigation start and navigation complete (React Router v6+, Vue Router 4, or a hand-rolled equivalent); the snippets are framework-agnostic apart from the React example in Step 4.
Step 1: Take manual control with feature detection
Switch off 'auto' only after confirming the property exists, and only once at startup. Overriding it on a browser that lacks the property would throw; overriding it before you have a save/restore pipeline ready would strand users at the top.
// TypeScript 5.x — framework-agnostic
export function enableManualScrollRestoration(): boolean {
if (!('scrollRestoration' in history)) {
// Legacy engine: leave the browser in charge, do nothing else.
return false;
}
try {
history.scrollRestoration = 'manual';
return true;
} catch {
// Some embedded webviews expose the property but reject writes.
return false;
}
}
Step 2: Capture position into the current history entry
Just before navigating away, write the live scroll offset onto the current entry with replaceState — not pushState, because you are annotating the entry you are leaving, not creating a new one. Doing this with pushState here would duplicate entries; the distinction matters enough that Preventing Duplicate History Entries with replaceState treats it as its own topic.
// TypeScript 5.x — framework-agnostic
// Call this from your router's "navigation is about to start" hook.
export function captureScrollPosition(): void {
const next = {
...history.state,
__scroll: { scrollY: window.scrollY, scrollX: window.scrollX },
};
// replaceState: mutate the entry we are leaving, do not grow the stack.
history.replaceState(next, '', window.location.href);
}
Step 3: Restore only on back/forward, defer until layout exists
Restoration belongs in the popstate handler, because that event — and only that event — fires for back/forward and history.go(), never for programmatic pushes. Read the saved offset from event.state, then wait one frame so the new view has been laid out before scrolling.
// TypeScript 5.x — framework-agnostic
interface SavedState { __scroll?: { scrollY: number; scrollX: number }; }
export function installPopstateRestore(): () => void {
const onPopState = (event: PopStateEvent) => {
const saved = (event.state as SavedState | null)?.__scroll;
if (!saved) {
// Forward-style entry with no record: start at the top.
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
return;
}
// Two-stage defer: rAF gives the router a frame to commit the DOM,
// the inner rAF lets the browser perform first layout before we scroll.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
window.scrollTo({ top: saved.scrollY, left: saved.scrollX, behavior: 'instant' });
});
});
};
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}
Step 4: Wire it into a framework router
In React Router the two halves attach to useLocation: capture on the way out of an entry, restore when the new pathname commits. Keeping focus management here also protects accessibility, since a programmatic scroll otherwise leaves the keyboard focus on a now-offscreen element. This same hook is the foundation for the container-aware techniques in Manual Scroll Restoration for SPAs.
// react-router-dom v6.22 — React 18
import { useEffect } from 'react';
import { useLocation, useNavigationType } from 'react-router-dom';
export function useScrollRestoration(): void {
const location = useLocation();
const navType = useNavigationType(); // 'POP' | 'PUSH' | 'REPLACE'
useEffect(() => {
if (navType !== 'POP') {
// PUSH/REPLACE are forward intent: land at the top.
window.scrollTo({ top: 0, behavior: 'instant' });
(document.querySelector('main') as HTMLElement | null)?.focus();
return;
}
const saved = (history.state as { __scroll?: { scrollY: number } } | null)?.__scroll;
if (typeof saved?.scrollY !== 'number') return;
requestAnimationFrame(() => {
window.scrollTo({ top: saved.scrollY, behavior: 'instant' });
});
}, [location.key, navType]);
}
Step 5: Hold position through late-arriving content
When document height changes after restoration — a hero image decodes, a font swaps, a data grid mounts — a one-shot scroll is already wrong. Re-apply the target while the document is still growing toward it, using a ResizeObserver that disconnects once the page is tall enough or a short budget elapses.
// TypeScript 5.x — framework-agnostic
export function restoreWithGrowth(targetY: number, budgetMs = 1500): void {
const start = performance.now();
window.scrollTo({ top: targetY, behavior: 'instant' });
const ro = new ResizeObserver(() => {
const reachable = document.documentElement.scrollHeight - window.innerHeight;
// Re-assert the position now that more content has expanded the page.
window.scrollTo({ top: Math.min(targetY, reachable), behavior: 'instant' });
if (reachable >= targetY || performance.now() - start > budgetMs) {
ro.disconnect();
}
});
ro.observe(document.documentElement);
// Hard stop in case height never reaches the target (deleted content).
setTimeout(() => ro.disconnect(), budgetMs);
}
For an anchor that must align precisely regardless of how much loads above it, switch from a fixed offset to an element-relative target. An IntersectionObserver confirms the element exists and is measurable before scrolling, with a timeout fallback so a never-rendered anchor cannot hang the restore.
// TypeScript 5.x — framework-agnostic
export async function restoreToElement(elementId: string, fallbackY = 0, timeoutMs = 2000): Promise<void> {
const el = document.getElementById(elementId);
if (!el) return window.scrollTo({ top: fallbackY, behavior: 'instant' });
// Settle web fonts first to avoid a font-swap reflow after we scroll.
await document.fonts.ready;
await new Promise<void>((resolve) => {
const io = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
io.disconnect();
window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY, behavior: 'instant' });
resolve();
}
}, { threshold: 0.1 });
io.observe(el);
setTimeout(() => { io.disconnect(); window.scrollTo({ top: fallbackY, behavior: 'instant' }); resolve(); }, timeoutMs);
});
}
Verification & Testing
Manual restoration is a timing problem, so a test that only asserts the final scroll position can pass on a fast machine and fail under throttling. Drive a real navigation, then assert the offset after the view settles. The Playwright spec below scrolls, navigates away, returns via the back button, and checks the position survived.
// @playwright/test v1.44 — Node 20
import { test, expect } from '@playwright/test';
test('restores scroll on back navigation', async ({ page }) => {
await page.goto('/catalogue/');
await page.evaluate(() => window.scrollTo({ top: 1800, behavior: 'instant' }));
// Let the router persist the position into history.state.
await page.getByRole('link', { name: 'Widget 42' }).click();
await expect(page).toHaveURL(/\/catalogue\/widget-42\/$/);
await page.goBack();
// Poll rather than snapshot: restoration is deferred across frames.
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(1700);
// Forward navigation must NOT restore — it should land at the top.
await page.goForward();
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeLessThan(50);
});
For a quick check without a test runner, set history.scrollRestoration = 'manual' in DevTools, then watch history.state.__scroll update in the console as you navigate — if the object is absent after a route change, your capture hook is not firing.
Performance Tuning
- Make the scroll write cheap. A
scrollTofollowed immediately by readingscrollHeightorgetBoundingClientRectforces a synchronous layout. Batch all reads before writes within a frame, and never callscrollToinside a scroll-event listener. - Choose storage by cost, not capacity. History state covers same-session back/forward at zero I/O cost and is the default. Reach for persistence only for reload survival:
sessionStorageis synchronous and tab-scoped (~5 MB),localStorageis synchronous and cross-session, and IndexedDB is asynchronous and quota-managed — the only option that keeps a large position cache off the main thread. On low-end mobile, a synchronouslocalStorage.setItemon every navigation is measurable transition latency.
// TypeScript 5.x — framework-agnostic; IndexedDB for reload-surviving positions
const DB = 'scroll_v1', STORE = 'pos';
function open(): Promise<IDBDatabase> {
return new Promise((res, rej) => {
const r = indexedDB.open(DB, 1);
r.onupgradeneeded = () => r.result.createObjectStore(STORE, { keyPath: 'url' });
r.onsuccess = () => res(r.result);
r.onerror = () => rej(r.error);
});
}
export async function persist(url: string, y: number): Promise<void> {
const db = await open();
db.transaction(STORE, 'readwrite').objectStore(STORE).put({ url, y, t: Date.now() });
}
export async function read(url: string, maxAgeMs = 3_600_000): Promise<number | null> {
const db = await open();
return new Promise((res) => {
const req = db.transaction(STORE, 'readonly').objectStore(STORE).get(url);
req.onsuccess = () => {
const rec = req.result as { y: number; t: number } | undefined;
res(rec && Date.now() - rec.t < maxAgeMs ? rec.y : null);
};
req.onerror = () => res(null);
});
}
- Cap and expire the cache. A TTL (the
maxAgeMscheck above) and a bounded entry count prevent the unbounded growth that producesQuotaExceededErroron long-lived applications. - Prefer
behavior: 'instant'for restoration. Smooth scrolling on a back navigation animates the viewport over hundreds of milliseconds, which reads as lag and can fight a second restoration pass triggered by late content.
Gotchas & Failure Modes
- Restoring on forward navigation. Only
popstate(ornavType === 'POP') should restore. Applying a saved offset on a fresh push sends the user to a stale position in brand-new content. Branch on navigation type explicitly. - Mobile viewport volatility. iOS Safari changes
window.innerHeightas its address bar collapses and expands, so an offset captured with the bar visible is wrong when it is hidden. Treat the dynamic viewport as a moving target and re-assert position onresize. - Nested and virtualised scroll containers.
window.scrollYignores overflow on modals, side panels, andiframecontent, and a virtualised list has no realscrollHeightuntil rows mount. These need per-container capture and index-based restoration — the focus of Manual Scroll Restoration for SPAs. - Deep-linking into a long page. Restoring or anchoring to an element that loads below the fold collides with the same height-growth problem; pair the element-relative restore in Step 5 with the URL handling in Deep Linking Implementation so a shared link and a back navigation land in the same place.
- Leaving
'auto'on for simple routes. Manual mode adds JavaScript and bug surface. If a route does no virtualisation and re-renders synchronously, native'auto'is faster and preserves browser-level accessibility — only opt out where you have a measured failure.
Go Deeper
- Manual Scroll Restoration for SPAs — extends these strategies to nested scroll containers, virtualised lists, and per-element restoration where a single
window.scrollYis not enough.
Related
- History API & State Management — the parent area covering history entries, state payloads, and navigation interception that scroll restoration builds on.
- popstate Event Handling — how to detect back/forward navigation reliably, the signal that decides when to restore versus reset.
- Deep Linking Implementation — encoding view state in the URL so shared links and restored navigations resolve to the same position.
- Manual Scroll Restoration for SPAs — pixel-accurate restoration for modals, data grids, and infinite-scroll feeds.