Infinite scroll loads page 80 and the tab runs out of memory
An infinite feed has to page forever without keeping forever, which means capping retained pages as well as virtualizing the DOM
What you'll build: a feed that pages forever without keeping forever — useInfiniteQuery + maxPages, an intersection sentinel for fetchNextPage, and virtualization so the DOM stays a window — optionally bidirectional with fetchPreviousPage.
The scenario
A social feed uses useInfiniteQuery without maxPages. Power users scroll for 20 minutes → 90 pages × 20 items in data.pages, and the UI maps every item into the DOM. Chrome tab climbs past 1GB; scroll janks; low-end Androids OOM-kill the tab. "Load more" works; the product dies from success.
A partial fix virtualizes the list but still retains 90 pages in the Query cache — better DOM, still fat RAM + slow serialize to DevTools/persist.
Why it escaped QA: fixtures stop at 2–3 pages; QA scrolls casually; memory isn't in the checklist.
Walkthrough
Stage 1 — Name it: pages accumulate until you cap them
Infinite queries store { pages, pageParams }. Each fetchNextPage appends. Without maxPages, growth is unbounded. Virtualization (sibling recipe) caps DOM; maxPages caps cache.
Stage 2 — Reject fake fixes
- Only virtualize — DOM fixed, cache still grows (and persist/hydrate suffers).
- Window
onscroll+useStateconcat — lose dedup/cancel/GC; race city. gcTime: 0— nukes cache on unmount; doesn't help a long-lived feed mount.
Stage 3 — Infinite query + sentinel + maxPages (+ virtualize)
import { useInfiniteQuery } from "@tanstack/react-query";import { useEffect } from "react";import { useInView } from "react-intersection-observer";
async function fetchFeedPage({ pageParam, signal }: { pageParam: number; signal: AbortSignal }) { const res = await fetch(`/api/feed?page=${pageParam}&limit=20`, { signal }); if (!res.ok) throw new Error("feed failed"); return res.json() as Promise<FeedItem[]>;}
export function useFeed() { return useInfiniteQuery({ queryKey: ["feed", "home"], queryFn: ({ pageParam, signal }) => fetchFeedPage({ pageParam, signal }), initialPageParam: 1, getNextPageParam: (last, all) => (last.length === 0 ? undefined : all.length + 1), maxPages: 3, // keep ~60 items warm });}
export function Feed() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } = useFeed(); const { ref, inView } = useInView({ rootMargin: "200px" });
useEffect(() => { if (inView && hasNextPage && !isFetchingNextPage) void fetchNextPage(); }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
if (isPending) return <FeedSkeleton />; const items = data.pages.flat();
return ( <div> {/* Prefer useVirtualizer here when items are heavy — see virtualization recipe */} {items.map((item) => ( <FeedCard key={item.id} item={item} /> ))} <div ref={ref}>{isFetchingNextPage ? "Loading…" : hasNextPage ? "…" : "End"}</div> </div> );}Compose with TanStack Virtual when row cost is high — same as variation 3 in the virtualization recipe, now with a real owner section.
Stage 4 — Bidirectional + verify
When maxPages drops early pages, scrolling up needs getPreviousPageParam + fetchPreviousPage + a top sentinel — otherwise users hit empty space. Virtualizers often restore scroll offset when prepending; test that UX.
Verify the loop. Scroll until page 6+: DevTools shows ≤ maxPages entries in pages. Memory stable vs uncapped baseline. Near-end sentinel triggers exactly one in-flight fetchNextPage. Optional: scroll up after cap → previous page reloads.
Variations
- Button "Load more" — no IntersectionObserver; same query.
- Cursor/nextToken pages —
getNextPageParam: (last) => last.nextCursor. - Virtualized window — required for heavy rows.
useSuspenseInfiniteQuery— initial suspend; see suspense-infinite-refresh.- Persist feed — usually don't; or persist only page 1 (filter in shouldDehydrate).
Trade-offs and common pitfalls
- No
maxPages— RAM death. - Flatten + mount all — DOM death even with maxPages if max is large.
- Wrong
getNextPageParam— infinite refetch loop (always returns next). - Missing
initialPageParam(v5) — required. - Observer fires every pixel without guard — check
isFetchingNextPage. - Keys on page index — remount flicker; key on item id.
- Ignoring upward scroll after trim — blank hole.
- Prefetching 50 pages on mount — not infinite scroll, a DOS.
- Abort-less
queryFn— page changes pile HTTP. - Testing only 2 pages — never see growth.
When NOT to infinite-scroll
Search results, admin tables, and shareable page URLs usually want discrete pagination. Infinite scroll is for feeds where position-in-stream matters more than addressable pages — and you must budget RAM.
See also
- Infinite queries
- Virtualization long list
- Suspense infinite refresh blanks feed
References
- TanStack Query — Infinite Queries
Demo source
demos/data-fetching/infinite-scroll-rams-the-tab/— capped vs uncapped memory. (Demo host TBD)