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

Saving and restoring scroll position with manual scrollRestoration On navigate the current scrollY is written into history.state; on a back or forward gesture the stored value is read and the viewport is restored. Save on navigate, restore on back scrollRestoration = 'manual' hands scroll control to your code Before navigate read window.scrollY replaceState save {scrollY} on entry History entry state.scrollY = 420 Back / forward popstate fires Read state y = event.state.scrollY Restore scrollTo(0, y) after paint the same history entry carries scrollY across both phases
The save phase stores scrollY on the current history entry as the user leaves; the restore phase reads it back on popstate and scrolls the viewport once layout has settled.

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);
  });
}
What scroll should do for each navigation kind Five navigation kinds with the correct scroll outcome for each: forward navigations reset, traversals restore from the entry's own state, in-place refinements do not move, and fragment links are left to the browser. Restoration behaviour by navigation kind scroll should stored where forward to a new route go to the top nothing to store back to a previous route return to the saved offset that entry's history state forward again after a back return to its saved offset that entry's history state a filter change in place stay exactly where it is nothing to store a fragment link the target element the browser handles it
Offsets belong to entries, not to routes — which is why the history state object is the right store.

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 scrollTo followed immediately by reading scrollHeight or getBoundingClientRect forces a synchronous layout. Batch all reads before writes within a frame, and never call scrollTo inside 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: sessionStorage is synchronous and tab-scoped (~5 MB), localStorage is 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 synchronous localStorage.setItem on 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 maxAgeMs check above) and a bounded entry count prevent the unbounded growth that produces QuotaExceededError on 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.

Ordering Scroll Against Focus and Rendering

Scroll restoration fails far more often because of when it runs than because of what it restores. Three things compete for the same moment after a navigation — rendering, focus, and scroll — and they have a correct order that is not the obvious one.

Render first, always. Applying a scroll offset before the new view exists sets a position the document cannot hold: the browser clamps it to the current height, and the visible result is a page that lands near the top and then, once content arrives, either stays wrong or jumps. Restoring after the commit costs one frame and removes the entire class of problem.

Focus second, with scrolling suppressed. Moving focus to the new heading is required for accessibility, and focus() scrolls its target into view by default. On a forward navigation that is harmless. On a back navigation it actively fights restoration: the focus call scrolls to the top, the restoration scrolls to the saved offset, and the user sees a visible flash between the two. Passing preventScroll: true makes the focus call silent and leaves the position decision to the code that owns it.

Scroll last, and only when it is a traversal. A forward navigation to a new view should start at the top. A traversal should return to the saved offset. An in-place refinement — a filter, a sort, a search — should not move the page at all, because the user is looking at something and has not asked to go anywhere. Getting this wrong in the third case is the most annoying of the three, since it moves the page while the user is reading.

There is a fourth actor worth disabling explicitly. The browser’s own restoration continues to run unless history.scrollRestoration is set to "manual", and two systems restoring the same position produce a result that depends on which finishes last. Setting it manually is a whole-document decision, so once you take it you own restoration everywhere — including on routes where the automatic behaviour would have been perfectly adequate.

Finally, content that arrives after restoration will move whatever you restored. Images without intrinsic dimensions are the usual culprit, and the fix is not in the restoration layer at all: setting width and height or an aspect-ratio on every image removes the shift at its source rather than compensating for it afterwards.

Gotchas & Failure Modes

  • Restoring on forward navigation. Only popstate (or navType === '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.innerHeight as 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 on resize.
  • Nested and virtualised scroll containers. window.scrollY ignores overflow on modals, side panels, and iframe content, and a virtualised list has no real scrollHeight until 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.
Five causes of a scroll restore that lands wrong Five reasons a restored scroll position is incorrect: restoring before render, images without dimensions, focus scrolling the target into view, the browser's own restoration still enabled, and a list whose height varies between visits. Why a restored offset lands in the wrong place cause fix restoring before content renders the document is shorter than the offset restore after the commit images without dimensions everything below them shifts set width/height or aspect-ratio focus() without preventScroll focus scrolls the target into view pass preventScroll: true browser auto-restoration still on two systems fighting set scrollRestoration = "manual" variable-height infinite list the offset has no meaning anchor to an item instead
The last row is the one that cannot be fixed by timing — a changing document height needs an anchor, not an offset.