Scroll Restoration in Infinite Scroll Lists

After reading this you will be able to return a user to their exact place in an infinite list after a detail-page visit and a Back press, using item anchoring rather than a pixel offset that no longer means anything.

← Back to Scroll Restoration Strategies

Prerequisites

Core Concept

Ordinary scroll restoration stores a pixel offset and reapplies it. That works when the page is the same height on the way back as it was on the way out. In an infinite list it never is.

The user scrolls to item 340 across seven loaded pages, taps into a detail view, and presses Back. The list component remounts holding page one — forty items, a few thousand pixels tall. Restoring an offset of 18,400 px does nothing, because the document is not that tall yet. If the application then loads the remaining pages, the offset becomes valid several seconds later, by which time the user has already started scrolling and gets yanked somewhere unexpected.

The fix is to change what is stored. Rather than “how far down the page”, record which item was at the top of the viewport and how far into it the user had scrolled. An item id is stable regardless of how many items are currently rendered, so restoration becomes: load enough pages to contain that item, find it, and scroll it to the position it occupied.

That reframing brings a second requirement with it. The list must be able to resume from an arbitrary point rather than always from page one — either by restoring the cached pages from the session or by requesting a window of items around the anchor. A cursor-based API makes this straightforward; an offset-based one makes it possible but fragile if items have been inserted in the meantime.

Pixel offset restoration versus item anchoring On the left, a saved pixel offset of eighteen thousand four hundred is applied to a remounted list that only contains page one, so the scroll does nothing and later jumps. On the right, the saved item id is used to load the pages containing it and scroll that item back to its original viewport position. Pixel offset — meaningless after a remount saved: scrollY = 18400 on the way out 7 pages loaded document 22,000px on the way back 1 page loaded document 3,100px scroll clamps to 3,100 then jumps seconds later Item anchor — stable regardless of height saved: { itemId: "sku-3412", offset: 26 } 1 · load pages up to the anchor's page 2 · find the element 3 · scroll it to offset 26px from the viewport top exact, first try The anchor survives inserted, removed and reordered items; a pixel offset survives none of them.
An item id keeps its meaning while the document height changes underneath it, which is the whole problem with an infinite list.

Implementation

Take the browser’s automatic restoration out of the picture first, then capture an anchor before leaving.

// TypeScript 5.x — turn off automatic restoration and capture an item anchor

if ("scrollRestoration" in history) {
  history.scrollRestoration = "manual";
}

interface ListAnchor {
  itemId: string;
  /** Distance from the viewport top to the item's top, in CSS pixels. */
  offset: number;
  /** How many pages had been loaded, so we can resume the same window. */
  pagesLoaded: number;
}

export function captureAnchor(listEl: HTMLElement): ListAnchor | null {
  const items = listEl.querySelectorAll<HTMLElement>("[data-item-id]");

  for (const item of items) {
    const rect = item.getBoundingClientRect();
    // The first item whose bottom is still below the viewport top is the anchor.
    if (rect.bottom > 0) {
      return {
        itemId: item.dataset.itemId!,
        offset: Math.round(rect.top),
        pagesLoaded: Number(listEl.dataset.pagesLoaded ?? "1"),
      };
    }
  }
  return null;
}

Write the anchor into the history entry as the user leaves, so it travels with that entry rather than living in a global:

// TypeScript 5.x — store the anchor on the entry the user is leaving

export function saveAnchorBeforeLeaving(listEl: HTMLElement): void {
  const anchor = captureAnchor(listEl);
  if (!anchor) return;
  history.replaceState({ ...(history.state as object ?? {}), listAnchor: anchor }, "");
}

Restoration is the interesting half, because the anchor may not be in the DOM yet. Load first, then position, and give up gracefully if the item has been deleted:

// TypeScript 5.x — restore by loading up to the anchor, then scrolling to it

export async function restoreAnchor(
  listEl: HTMLElement,
  loadPage: (page: number) => Promise<void>,
): Promise<boolean> {
  const anchor = (history.state as { listAnchor?: ListAnchor } | null)?.listAnchor;
  if (!anchor) return false;

  // 1. Restore the same window of pages the user had open.
  for (let page = 1; page <= anchor.pagesLoaded; page++) {
    if (listEl.querySelector(`[data-item-id="${CSS.escape(anchor.itemId)}"]`)) break;
    await loadPage(page);
  }

  // 2. Locate the anchor. It may be gone — deleted, filtered out, or moved.
  const target = listEl.querySelector<HTMLElement>(
    `[data-item-id="${CSS.escape(anchor.itemId)}"]`,
  );
  if (!target) return false; // caller falls back to the top of the list

  // 3. Position it exactly where it was, after layout has settled.
  await new Promise((resolve) => requestAnimationFrame(resolve));
  const delta = target.getBoundingClientRect().top - anchor.offset;
  window.scrollBy({ top: delta, behavior: "instant" as ScrollBehavior });
  return true;
}

Two refinements make the difference between “usually right” and “right”:

// TypeScript 5.x — hold the anchor steady while images and lazy content settle

export function keepAnchorStable(listEl: HTMLElement, itemId: string, offset: number): () => void {
  const target = listEl.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(itemId)}"]`);
  if (!target) return () => {};

  // Content loading ABOVE the anchor changes its position; correct for it.
  const observer = new ResizeObserver(() => {
    const drift = target.getBoundingClientRect().top - offset;
    if (Math.abs(drift) > 1) window.scrollBy({ top: drift, behavior: "instant" as ScrollBehavior });
  });
  observer.observe(listEl);

  // Stop correcting as soon as the user takes control.
  const stop = () => { observer.disconnect(); window.removeEventListener("wheel", stop); };
  window.addEventListener("wheel", stop, { once: true, passive: true });
  window.addEventListener("touchstart", stop, { once: true, passive: true });

  return stop;
}

The complementary CSS is worth knowing about: overflow-anchor: auto — the default — already keeps the browser from shifting content when things load above the viewport, and explicitly disabling it on a list container is a common self-inflicted cause of jumping.

The restore sequence for an anchored infinite list A four-step sequence: read the anchor from the history entry, load pages until the anchor item exists, wait one animation frame for layout, then scroll by the difference between the item's current position and its saved offset. A fallback path scrolls to the top when the item no longer exists. Restore sequence 1 · read anchor from history.state 2 · load pages until the item exists 3 · wait a frame let layout settle 4 · scrollBy delta to saved offset item no longer exists — deleted, filtered out, or reordered fall back to the top of the list rather than guessing a nearby position
Load, settle, then position: scrolling before the anchor exists or before layout settles is what produces the second jump.

Verification

// @playwright/test v1.44
import { test, expect } from "@playwright/test";

test("returning from a detail page restores the user's place in the list", async ({ page }) => {
  await page.goto("/products");

  // Scroll far enough to load several pages.
  for (let i = 0; i < 6; i++) {
    await page.mouse.wheel(0, 4000);
    await page.waitForTimeout(200);
  }

  const anchorId = await page.evaluate(() => {
    const items = [...document.querySelectorAll<HTMLElement>("[data-item-id]")];
    return items.find((el) => el.getBoundingClientRect().bottom > 0)?.dataset.itemId;
  });

  await page.locator(`[data-item-id="${anchorId}"] a`).click();
  await expect(page.locator("[data-route='product-detail']")).toBeVisible();
  await page.goBack();

  // The anchor item must be back at the top of the viewport, within a few pixels.
  const top = await page.locator(`[data-item-id="${anchorId}"]`)
    .evaluate((el) => el.getBoundingClientRect().top);
  expect(Math.abs(top)).toBeLessThan(8);

  // And it must not drift afterwards as images finish loading.
  await page.waitForTimeout(1500);
  const settled = await page.locator(`[data-item-id="${anchorId}"]`)
    .evaluate((el) => el.getBoundingClientRect().top);
  expect(Math.abs(settled - top)).toBeLessThan(8);
});

The manual check is the one users perform: scroll deep, open an item, press Back, and see whether you are where you were. Then do it again on a throttled connection, which is where offset-based restoration fails most visibly.

Gotchas

  • Restoring before the anchor is in the DOM does nothing. The scroll is clamped to the current document height and the later correction reads as a jump.
  • Images without dimensions shift everything below them. Setting width and height (or aspect-ratio) on list images removes most of the drift the ResizeObserver is compensating for.
  • Stop correcting the moment the user scrolls. A correction loop that outlives the user’s first wheel event feels like the page is fighting them.
  • Virtualised lists need index anchoring, not element anchoring. The element does not exist off-screen, so store the item index and the scroll container’s offset within it.
  • Anchors must be per-entry, not global. Two list routes open in the same session will otherwise overwrite each other’s positions.
  • A deleted anchor item is normal, not exceptional. Fall back to the top of the list rather than to the nearest surviving item, which lands the user somewhere they never were.
Five reasons a saved anchor cannot be located Five causes for a missing scroll anchor: the item was deleted, a filter excluded it, the sort order moved it, the pages have not loaded yet, or a deployment changed the id format. All but one resolve to scrolling to the top of the list. Why the anchor may not be found on return cause correct response the item was deleted gone from the data entirely scroll to the top of the list a filter changed excluded from this result set top of the list, not a guess the sort order changed present but somewhere else top of the list pages have not loaded yet still fetching wait, then position the id format changed a deploy broke the key fall back silently
Only one of these is worth waiting for; the rest should land the user at the top rather than at a guessed position.

FAQ

Why not just cache the whole rendered list? You can, and for short lists it is simpler. For an infinite list the cache is unbounded, and keeping thousands of DOM nodes alive across navigations costs more memory than the feature is worth.

Does scrollRestoration = "manual" break normal pages? It disables the browser’s automatic restore for the whole document, so once you set it you own restoration everywhere — including on ordinary routes. Set it deliberately, not as a local fix.

What if items are inserted at the top between visits? That is precisely the case anchoring handles: the anchor item moves down, and the restore scrolls to wherever it now is, rather than to a stale pixel offset.

Should the anchor go in the URL? No. It is per-entry, per-session state that nobody would want in a shared link — the archetypal use of the history state object.

How does this interact with focus restoration? Restore focus first with preventScroll, then apply the anchor. Doing it the other way round lets the focus call undo the scroll you just set.