CORE JSC

International Technology Partnership

Web Development & SEO

Fixing Infinite Scroll Lists That Lose Scroll Position When a User Navigates Back

A user scrolls thirty items deep into an infinite-scroll feed, opens an item, then taps back — and lands at the very top of the list, forced to scroll all thirty items again to find their place. The forward navigation and the data are both working fine; what's missing is a way to actually restore where the user was.

Core JSC Team·September 13, 2026
Web DevelopmentInfinite ScrollScroll RestorationSPA RoutingUX

The Problem

A feed, product listing, or search results page loads items in batches as the user scrolls (infinite scroll or "load more" pagination). A user scrolls deep into the list, clicks an item to view its details, then presses the browser's or app's back button expecting to return to roughly where they were. Instead, the list re-mounts from scratch at the top, only the first batch of items is loaded again, and the user has to re-trigger every subsequent batch load and scroll all the way back down to find the item they came from. Nothing errors — the feature that's missing is scroll and data-state restoration on back navigation, not a bug in the forward flow.

Why It Happens

Client-side routing usually re-mounts the list component on navigation

In most single-page app routing setups, navigating away from a list page and back to it unmounts the list component and mounts a fresh instance when the route matches again. A fresh instance has no memory of how many batches were previously loaded or where the scroll offset was — it starts from its initial state exactly as if the user were visiting for the first time.

Scroll position and loaded-data state are two separate problems that both need solving

Restoring scroll position alone doesn't help if the list only has its first batch of items loaded — scrolling back to pixel offset 4000 on a list that only rendered the first 20 items has nothing to land on. Both the accumulated data (how many batches, which items) and the scroll offset need to be restored together for the return to actually feel seamless.

The browser's native scroll restoration only handles full page navigations, not SPA route changes or virtualized lists

Browsers do have native scroll restoration for traditional multi-page navigation, but single-page apps that intercept navigation via the History API bypass this entirely by default, and a virtualized list (rendering only the DOM nodes currently in view) has no scrollable content at the restored offset until the underlying data is back in place either way.

Caching the list's state only helps if it's actually kept somewhere that survives the unmount

If loaded items and scroll position live only in local component state, they're destroyed the moment the component unmounts on navigation — there's no state left to restore from by the time the user navigates back, regardless of how the restoration logic itself is written.

The Fix

1. Persist the list's loaded data and scroll offset outside the component that unmounts

// A simple in-memory cache keyed by list identity, outside component state
const listStateCache = new Map();

function useRestorableList(listKey, fetchPage) {
  const cached = listStateCache.get(listKey);
  const [items, setItems] = useState(cached?.items ?? []);
  const [page, setPage] = useState(cached?.page ?? 0);

  useEffect(() => {
    return () => {
      listStateCache.set(listKey, { items, page, scrollY: window.scrollY });
    };
  }, [listKey, items, page]);

  return { items, page, setItems, setPage, restoredScrollY: cached?.scrollY };
}

Storing the accumulated items, current page/batch count, and scroll offset in a cache that lives outside the component's own state — a module-level map, a router-level cache, or a state management store — means the data survives the unmount, so there's actually something to restore from when the user comes back.

2. Restore the scroll position only after the cached data has re-rendered

useLayoutEffect(() => {
  if (restoredScrollY != null && items.length > 0) {
    window.scrollTo(0, restoredScrollY);
  }
}, [restoredScrollY, items.length]);

Scrolling to the saved offset has to happen after the previously loaded items are back in the DOM, not before — restoring scroll position against a list that's still empty or only partially rendered lands the user somewhere meaningless, or gets silently clamped back to the top.

3. For virtualized lists, restore the virtualization library's own index/offset state, not just window scroll

// Example with a virtualization library exposing an imperative scroll API
useEffect(() => {
  if (restoredIndex != null) {
    virtualListRef.current?.scrollToIndex(restoredIndex, { align: "start" });
  }
}, [restoredIndex]);

A virtualized list manages its own internal scroll and rendered-range state separately from the window's scroll position. Restoring window.scrollY alone does nothing useful here — the library's own imperative scroll-to-index API has to be used so it re-renders the correct window of items at the correct visual position.

4. Invalidate the cached state deliberately, not accidentally

function invalidateListCache(listKey) {
  listStateCache.delete(listKey);
}
// Call this after an action that should genuinely reset the list —
// e.g. the user changes a filter or pulls to refresh — not on every unmount

Restoring state indefinitely can itself become a bug if stale data lingers after something that should have reset it — a changed filter, a new search query, a manual refresh. The cache needs an explicit invalidation point tied to actions that genuinely change what the list should show, so restoration doesn't fight against intentional resets.

Why This Works

Each fix addresses a distinct part of what "coming back to where you were" actually requires. Persisting data and scroll state outside the unmounting component ensures there's something left to restore from at all; sequencing the scroll restoration after the data re-renders prevents scrolling to a position that doesn't exist yet; handling virtualized lists through their own imperative API accounts for the fact that window scroll and virtualized rendering are two separate systems; and deliberate cache invalidation keeps the restored state from becoming stale data masquerading as a feature.

Conclusion

Infinite scroll losing its place on back navigation isn't a data-loading bug — the forward flow works exactly as designed; what's missing is a mechanism to preserve and restore both the accumulated data and the scroll position across an unmount. Cache the list's data and scroll offset outside component state, restore scroll only after the cached data has re-rendered, use a virtualized list's own scroll-to-index API rather than relying on window scroll alone, and invalidate the cache deliberately when the list's underlying query actually changes.