Losing your place in a long feed after tapping "back" is one of the most consistently reported infinite-scroll complaints — and fixing it requires restoring both the DOM state and the scroll offset together, not just the offset alone.
Problem / Scenario Context
A user scrolls through 40 items in a feed, taps into item 35, then presses the browser's back button. If the feed component re-mounts from scratch — as most single-page app routers do by default — it re-fetches only the first page and renders 10 items. Even if you naively restore scrollTop to its previously recorded pixel value, that offset now points into empty space, because the DOM is shorter than it was when the value was captured. The user lands on a blank area or gets silently reset to the top.
This problem sits squarely inside Infinite Scroll & Pagination, which documents the sentinel-based IntersectionObserver fetch loop that the DOM restoration below is built on top of. It also overlaps with content injected above the current viewport — for example, a "load newer items" flow or a bidirectional feed — where naive prepending causes a visible jump because the browser keeps scrollTop as a fixed pixel count rather than fixed to visual content.
Mechanics Explanation
Correct scroll restoration in an infinite scroll context requires reconstructing three pieces of state together, in this order:
- Loaded page count — how many batches were fetched before navigating away, so the DOM can be rebuilt to the same length before any scroll adjustment is attempted.
- Scroll offset — the raw
scrollToppixel value (or a resilient index-based equivalent) at the moment of navigation. - Height stabilization — a signal that tells you the restored DOM has finished laying out and it is now safe to apply the scroll offset, since applying it too early (before images or async content have sized themselves) lands on the wrong pixel.
ResizeObserver is the tool for step 3: observing the feed container's height and waiting for it to stabilize (or reach an expected minimum) before writing scrollTop, rather than guessing with a setTimeout. The CSS property overflow-anchor handles a related but distinct problem — keeping the visual position stable when content already in the DOM shifts height during the same session (e.g., a lazy-loaded image finishing decode) — but it cannot restore state across a full component remount, because by definition the old nodes no longer exist for the browser to anchor to.
The diagram below separates these two mechanisms and shows where each one applies.
Comparison Table: Scroll Restoration Strategies
| Strategy | Survives component remount | Handles prepended content | Storage mechanism | Best for |
|---|---|---|---|---|
CSS overflow-anchor: auto (default) |
No | Yes, automatically | None — browser-native | In-session height shifts (image decode, ad slots) |
Raw scrollTop in sessionStorage |
Yes, if DOM length matches | No | sessionStorage |
Simple feeds with a stable, predictable item height |
Page count + scrollTop + ResizeObserver gate |
Yes | Partially — needs manual delta correction | history.state + sessionStorage fallback |
Production infinite scroll with variable-height items |
| Index + in-viewport offset (virtualized) | Yes | Yes | history.state |
Windowed/virtualized lists with estimated heights |
Minimal Reproducible Example
This minimal version saves and restores a raw pixel offset with no page-count coordination — it works only when the feed happens to render exactly the same number of items on remount, which is rarely true in practice. It demonstrates the naive approach and its failure mode.
// Minimal version — fails whenever the DOM length differs from capture time
const STORAGE_KEY = 'feed-scroll-y';
function saveScrollPosition(container: HTMLElement): void {
sessionStorage.setItem(STORAGE_KEY, String(container.scrollTop));
}
function restoreScrollPosition(container: HTMLElement): void {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) container.scrollTop = Number(saved); // BUG: DOM may be shorter than before
}
window.addEventListener('beforeunload', () => {
const container = document.querySelector<HTMLElement>('#feed')!;
saveScrollPosition(container);
});
// Plain JS equivalent
const STORAGE_KEY = 'feed-scroll-y';
function saveScrollPosition(container) {
sessionStorage.setItem(STORAGE_KEY, String(container.scrollTop));
}
function restoreScrollPosition(container) {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) container.scrollTop = Number(saved);
}
If the feed only re-fetched page 1 (10 items) but the user had scrolled through 4 pages (40 items) before navigating away, scrollTop restores to a value far past the bottom of the shortened DOM — the browser clamps it to the maximum scrollable distance, and the user lands at the bottom instead of where they left off.
Production-Safe Solution
The controller below coordinates page-count restoration, a ResizeObserver-gated write of scrollTop, and safe prepend handling using a measured height delta.
interface ScrollRestoreState {
pageCount: number;
scrollTop: number;
savedAt: number;
}
interface FetchPage {
(page: number, signal: AbortSignal): Promise<unknown[]>;
}
const STORAGE_KEY = 'feed-scroll-state';
const MAX_STATE_AGE_MS = 30 * 60 * 1000; // 30 minutes
/**
* Coordinates page-count restoration and scroll offset restoration for an
* infinite scroll container. Uses history.state as the primary store (scoped
* per history entry) and sessionStorage as a same-tab fallback for reloads.
*/
export class ScrollRestoreController {
private container: HTMLElement;
private fetchPage: FetchPage;
private resizeObserver: ResizeObserver | null = null;
private abortController = new AbortController();
private restoring = false;
constructor(container: HTMLElement, fetchPage: FetchPage) {
this.container = container;
this.fetchPage = fetchPage;
}
/** Call on every successful page fetch, and before navigating away. */
saveState(pageCount: number): void {
const state: ScrollRestoreState = {
pageCount,
scrollTop: this.container.scrollTop,
savedAt: Date.now(),
};
// Primary: scoped to this history entry, survives back/forward correctly
history.replaceState({ ...history.state, scrollRestore: state }, '');
// Fallback: same-tab reloads where history.state may be cleared
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
private readState(): ScrollRestoreState | null {
const fromHistory = (history.state?.scrollRestore as ScrollRestoreState) ?? null;
if (fromHistory) return fromHistory;
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as ScrollRestoreState;
if (Date.now() - parsed.savedAt > MAX_STATE_AGE_MS) return null; // stale — ignore
return parsed;
} catch {
return null;
}
}
/**
* Re-fetches all previously loaded pages, then waits for the container's
* layout height to stabilize via ResizeObserver before writing scrollTop.
* Returns the restored page count so the caller's fetch loop can continue
* from the correct page for subsequent IntersectionObserver-triggered loads.
*/
async restore(renderPage: (items: unknown[]) => void): Promise<number> {
const state = this.readState();
if (!state) return 0;
this.restoring = true;
for (let page = 1; page <= state.pageCount; page++) {
const items = await this.fetchPage(page, this.abortController.signal);
renderPage(items);
}
await this.waitForStableHeight();
this.container.scrollTop = state.scrollTop;
this.restoring = false;
return state.pageCount;
}
/** Resolves once the container's height has not changed for two consecutive frames. */
private waitForStableHeight(): Promise<void> {
return new Promise((resolve) => {
let lastHeight = -1;
let stableFrames = 0;
this.resizeObserver = new ResizeObserver((entries) => {
const height = entries[0].contentBoxSize?.[0]?.blockSize ?? entries[0].contentRect.height;
if (Math.abs(height - lastHeight) < 1) {
stableFrames++;
} else {
stableFrames = 0;
lastHeight = height;
}
if (stableFrames >= 2) {
this.resizeObserver?.disconnect();
this.resizeObserver = null;
resolve();
}
});
this.resizeObserver.observe(this.container);
// Safety timeout — never block restoration indefinitely on a container
// that never quite stabilizes (e.g., a live-updating ticker above the feed).
setTimeout(() => {
this.resizeObserver?.disconnect();
this.resizeObserver = null;
resolve();
}, 1500);
});
}
/**
* Call when prepending items above the current scroll position (bidirectional
* feeds, "load newer" flows). Measures the inserted height and immediately
* compensates scrollTop so the currently visible content does not jump.
*/
prependWithAnchor(insert: () => HTMLElement[]): void {
const before = this.container.scrollHeight;
insert();
const delta = this.container.scrollHeight - before;
this.container.scrollTop += delta; // same paint cycle — no visible jump
}
get isRestoring(): boolean {
return this.restoring;
}
destroy(): void {
this.abortController.abort();
this.resizeObserver?.disconnect();
this.resizeObserver = null;
}
}
// Plain JS fallback (no TypeScript annotations)
const STORAGE_KEY = 'feed-scroll-state';
const MAX_STATE_AGE_MS = 30 * 60 * 1000;
export class ScrollRestoreController {
constructor(container, fetchPage) {
this.container = container;
this.fetchPage = fetchPage;
this.resizeObserver = null;
this.abortController = new AbortController();
this.restoring = false;
}
saveState(pageCount) {
const state = { pageCount, scrollTop: this.container.scrollTop, savedAt: Date.now() };
history.replaceState({ ...history.state, scrollRestore: state }, '');
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
readState() {
const fromHistory = history.state?.scrollRestore ?? null;
if (fromHistory) return fromHistory;
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (Date.now() - parsed.savedAt > MAX_STATE_AGE_MS) return null;
return parsed;
} catch { return null; }
}
async restore(renderPage) {
const state = this.readState();
if (!state) return 0;
this.restoring = true;
for (let page = 1; page <= state.pageCount; page++) {
const items = await this.fetchPage(page, this.abortController.signal);
renderPage(items);
}
await this.waitForStableHeight();
this.container.scrollTop = state.scrollTop;
this.restoring = false;
return state.pageCount;
}
waitForStableHeight() {
return new Promise((resolve) => {
let lastHeight = -1, stableFrames = 0;
this.resizeObserver = new ResizeObserver((entries) => {
const height = entries[0].contentRect.height;
if (Math.abs(height - lastHeight) < 1) stableFrames++;
else { stableFrames = 0; lastHeight = height; }
if (stableFrames >= 2) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
resolve();
}
});
this.resizeObserver.observe(this.container);
setTimeout(() => {
this.resizeObserver?.disconnect();
this.resizeObserver = null;
resolve();
}, 1500);
});
}
prependWithAnchor(insert) {
const before = this.container.scrollHeight;
insert();
const delta = this.container.scrollHeight - before;
this.container.scrollTop += delta;
}
destroy() {
this.abortController.abort();
this.resizeObserver?.disconnect();
this.resizeObserver = null;
}
}
CSS baseline — keep native scroll anchoring enabled for the in-session case; it costs nothing and handles image-decode height shifts automatically:
.feed-container {
overflow-y: auto;
overflow-anchor: auto; /* browser default; be explicit to avoid accidental overrides */
}
Combine this controller with the sentinel-based fetch loop from Infinite Scroll & Pagination: once restore() resolves with the restored page count, hand that count to your IntersectionObserver-driven loader so the next sentinel intersection fetches page pageCount + 1, not page 1. For feeds with very tall item counts, pair this pattern with virtual list windowing — in that case you restore an item index and in-viewport pixel offset rather than a raw scrollTop, since off-screen item heights are only estimated until they render.
Verification Steps
- Basic round-trip. Scroll 4 pages deep, click into a detail view, press back. Confirm the feed re-renders all 4 pages before any scroll adjustment, and the viewport lands on the same item the user was viewing.
- Slow network round-trip. Throttle to Slow 3G and repeat the round-trip. Confirm the page does not flash to the top before settling —
waitForStableHeight()should hold the scroll write until layout is done. - Prepend jump test. Trigger a "load newer" prepend while scrolled mid-feed. Record a screen capture and confirm the currently visible item does not move by more than a few pixels.
- Stale state handling. Manually set
savedAtinsessionStorageto more than 30 minutes in the past, reload, and confirm the controller ignores the stale state and starts fresh at the top. - Virtualized list check. If paired with virtual list windowing, confirm restoration scrolls to the correct index first, then applies the pixel offset, rather than attempting a raw
scrollTopwrite against estimated heights.
Common Mistakes to Avoid
- Restoring
scrollTopbefore the DOM has finished rendering the correct number of pages. This is the single most common cause of "scroll restoration doesn't work" bug reports — the write happens against a shorter DOM than the one the offset was captured against. - Using only
sessionStorageand neverhistory.state.sessionStorageis not scoped per history entry, so two tabs with the same feed at different scroll depths will overwrite each other's saved state. - Guessing readiness with a fixed
setTimeoutinstead ofResizeObserver. A fixed delay is either too short on slow connections (scroll write happens too early, lands wrong) or wastes time on fast ones. Gating on measured height stability adapts to actual conditions. - Forgetting the height-delta compensation on prepend. Simply inserting nodes above the current scroll position without adjusting
scrollTopby the inserted height causes an immediately visible jump, because the browser does not automatically compensate scroll offset for insertions outside the current viewport in all cases — measuringscrollHeightbefore and after is the reliable cross-browser fix. - Disabling
overflow-anchorglobally to "fix" flicker. This CSS property solves a different, complementary problem (in-session height shifts) and disabling it can reintroduce visible jumps from lazy-loaded images or ads, unrelated to the page-restoration logic.
FAQ
Why does the scroll position reset when a user navigates back to an infinite scroll feed?
By default, single-page applications re-render the feed component from scratch on route re-entry, starting with only the first page of items fetched again. The browser has no memory of how many pages were loaded before, so the DOM is shorter than it was and the previously recorded scroll offset now points past the end of the content. You must explicitly restore both the loaded page count and the scroll offset together.
What is scroll anchoring and does it solve this automatically?
CSS scroll anchoring (overflow-anchor: auto, the browser default) keeps the visual position stable when content changes above the viewport during the same session, by adjusting scrollTop to compensate for height changes in already-rendered content. It does not solve back/forward navigation restoration, because that requires re-fetching and re-rendering content that no longer exists in the DOM at all — anchoring only works on nodes that are still present.
Should I store scroll position in sessionStorage or the History API state object?
Use the History API's state object (history.replaceState) as the primary mechanism, since it is scoped correctly to each history entry and survives forward/back navigation without collisions between tabs. Use sessionStorage as a fallback for cases where state is lost, such as a hard reload, since sessionStorage persists across reloads within the same tab but History state can be cleared by some browsers on reload.
How do I avoid a visible jump when prepending items above the current scroll position?
Measure the height of the prepended content with ResizeObserver (or a synchronous getBoundingClientRect call taken before and after the insert) and immediately add that height delta to scrollTop in the same paint cycle. This keeps the pixels the user is currently looking at visually fixed even though new nodes were inserted above them.
Does restoring scroll position work with virtualized or windowed lists?
Yes, but you restore an index and offset rather than a raw pixel value, since a virtualized list only renders a subset of items and their real height is often estimated. Store the last visible item's index and its offset within the viewport, then on restore scroll the virtualization engine to that index first and apply the pixel offset afterward.
Related
- Creating Smooth Infinite Scroll Without Jank — frame-budget-safe fetch and render scheduling for the same feed
- Infinite Scroll & Pagination — parent guide covering the sentinel-based fetch loop this restoration logic extends
- Virtual List & Windowing with Observers — index-based restoration for very large, windowed feeds
- Preventing Memory Leaks in Long-Running Observers — teardown discipline for the ResizeObserver used here
↑ Back to Infinite Scroll & Pagination