PAUL CHONGSenior Software Engineer

Infinite Scrolling & Virtualization

2026-08-05 · 6 min read

Infinite scrolling and virtualization solve two different problems that appear together in large lists. Infinite scroll manages data fetching — loading more items as the user approaches the bottom. Virtualization manages rendering — keeping the DOM small regardless of how many items have been fetched. You can use either independently, but at scale you need both.

Infinite Scrolling

Load the next page of data automatically as the user scrolls near the bottom of the list.

The naive implementation listens to scroll events and checks if the user is near the bottom:

window.addEventListener('scroll', () => {
  const nearBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 200;
  if (nearBottom && hasMore && !loading) loadMore();
});

This works but fires on every scroll tick — dozens of times per second. The modern approach is an IntersectionObserver watching a sentinel element placed at the bottom of the list:

const sentinel = useRef(null);

useEffect(() => {
  const observer = new IntersectionObserver(([entry]) => {
    if (entry.isIntersecting && hasMore && !loading) loadMore();
  });
  if (sentinel.current) observer.observe(sentinel.current);
  return () => observer.disconnect();
}, [hasMore, loading]);

// In JSX:
<ul>{posts.map(p => <PostCard key={p.id} post={p} />)}</ul>
<div ref={sentinel} />

When the sentinel enters the viewport, the observer fires once and loadMore fetches the next page. No scroll event polling, no rate limiting needed.

loadMore appends to existing state and advances the cursor (see Pagination: Offset vs Cursor):

async function loadMore() {
  const res = await fetchPosts({ cursor, count: 10 });
  setPosts(prev => [...prev, ...res.data]);
  setCursor(res.pageInfo.endCursor);
  setHasMore(res.pageInfo.hasNextPage);
}

TanStack Query's useInfiniteQuery manages this automatically — it accumulates pages, tracks cursors, and exposes fetchNextPage and hasNextPage without manual state.

Prefetching

By the time the sentinel is visible, the user has already hit the bottom. Place the sentinel earlier — one or two viewport heights above the actual end — so the next page loads before the user notices the boundary:

const observer = new IntersectionObserver(([entry]) => {
  if (entry.isIntersecting && hasMore && !loading) loadMore();
}, { rootMargin: '0px 0px 800px 0px' }); // trigger 800px before sentinel enters viewport

Virtualization

Only render the items currently visible in the viewport. Unmount everything else.

Without virtualization, every fetched item stays in the DOM indefinitely. After scrolling through 500 posts, there are 500 mounted components consuming memory and participating in layout calculations. At 1,000–2,000 items, scroll performance degrades noticeably. At 10,000+, the page can become unusable.

Virtualization solves this by maintaining a window over the list — only items within (and slightly around) the visible area are mounted. As the user scrolls, items entering the window mount, and items leaving unmount. The DOM node count stays constant regardless of list length.

How windowing works

The virtualizer needs to know the scroll position and each item's height to determine which items fall within the window:

visible range = [scrollTop, scrollTop + viewportHeight]
render range  = [scrollTop - overscan, scrollTop + viewportHeight + overscan]

overscan is extra items rendered above and below the visible area as a buffer — typically 3–5 items — so fast scrolling doesn't show a flash of empty space before new items mount.

The container holds the full list height (even though most items aren't rendered), so the scrollbar reflects the true length:

// Pseudocode for a fixed-height virtualizer
const ITEM_HEIGHT = 80;
const totalHeight = items.length * ITEM_HEIGHT;
const startIndex = Math.floor(scrollTop / ITEM_HEIGHT);
const endIndex = Math.min(items.length, Math.ceil((scrollTop + viewportHeight) / ITEM_HEIGHT));
const visibleItems = items.slice(startIndex - overscan, endIndex + overscan);

// Each item is absolutely positioned at its real offset
const offsetY = (startIndex - overscan) * ITEM_HEIGHT;
<div style={{ height: totalHeight, position: 'relative' }}>
  <div style={{ transform: `translateY(${offsetY}px)` }}>
    {visibleItems.map(item => <Row key={item.id} item={item} />)}
  </div>
</div>

Fixed vs variable height items

Fixed height is straightforward — item positions are computed in O(1). Variable height is harder: the virtualizer can't know an item's position without knowing the height of every item before it.

The standard approach is measure on mount: render an item, measure its actual height, store it, then use those measurements for future layout calculations. Items not yet measured get an estimated height until they've been rendered once.

// TanStack Virtual handles this with measureElement
const rowVirtualizer = useVirtualizer({
  count: posts.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 120,       // initial estimate
  measureElement: el => el.getBoundingClientRect().height,
});

Using a library

Writing a virtualizer from scratch is error-prone (scroll anchoring, resize handling, dynamic content, accessibility). Use TanStack Virtual (framework-agnostic) or react-window (simpler, fixed-size only).

import { useVirtualizer } from '@tanstack/react-virtual';

const rowVirtualizer = useVirtualizer({
  count: posts.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 120,
  overscan: 5,
});

return (
  <div ref={parentRef} style={{ height: '100vh', overflow: 'auto' }}>
    <div style={{ height: rowVirtualizer.getTotalSize() }}>
      {rowVirtualizer.getVirtualItems().map(virtualRow => (
        <div
          key={virtualRow.key}
          style={{ position: 'absolute', top: virtualRow.start, width: '100%' }}
        >
          <PostCard post={posts[virtualRow.index]} />
        </div>
      ))}
    </div>
  </div>
);

Combining infinite scroll and virtualization

Infinite scroll and virtualization compose naturally. The virtualizer renders only the visible window; infinite scroll appends to the underlying array. As the list grows, the DOM stays the same size.

The sentinel element goes inside the virtualizer as the last item — when it virtualizes into view, it triggers the next fetch:

const rowVirtualizer = useVirtualizer({
  count: hasMore ? posts.length + 1 : posts.length, // +1 for sentinel
  getScrollElement: () => parentRef.current,
  estimateSize: () => 120,
});

rowVirtualizer.getVirtualItems().map(virtualRow => {
  const isSentinel = virtualRow.index === posts.length;
  return isSentinel ? <LoadingSpinner /> : <PostCard post={posts[virtualRow.index]} />;
});

Scroll restoration

When a user navigates away and returns, they expect to land where they left off. With a virtualized list this requires storing and restoring the scroll offset explicitly — the browser's native scroll restoration doesn't work because the DOM is rebuilt on remount.

// Save on unmount
useEffect(() => {
  return () => sessionStorage.setItem('feedScroll', parentRef.current?.scrollTop);
}, []);

// Restore on mount
useEffect(() => {
  const saved = sessionStorage.getItem('feedScroll');
  if (saved) parentRef.current.scrollTop = Number(saved);
}, []);

For virtualized lists with variable height items, restoring by scroll offset is unreliable if new items have been inserted above. The more robust approach is to store the first visible item's index and scroll to it by index on restore.

When to use what

ScenarioApproach
List under ~200 items, slow-growingInfinite scroll only
List that can grow to thousandsInfinite scroll + virtualization
Static list, known lengthVirtualization only (no fetching)
Paginated UI with page numbersNeither — render the page, no windowing needed

Virtualization adds complexity: items must have predictable structure, dynamic content (images loading in, expanding rows) requires careful height measurement, and accessibility tooling can behave unexpectedly with unmounted nodes. Only reach for it when list size actually causes a performance problem.