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 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.

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.

Go Deeper

  • Manual Scroll Restoration for SPAs — extends these strategies to nested scroll containers, virtualised lists, and per-element restoration where a single window.scrollY is not enough.