Back to Performance
Performance
easy
mid

What is the performance model behind infinite scroll and virtual lists?

Infinite scroll = paginated fetching as the user scrolls; virtualization = rendering only the visible window of items even though the data set is in memory. They're orthogonal — combine for huge datasets. Without virtualization, 10k DOM nodes destroy scroll FPS and memory. Virtualization keeps DOM constant (~20 rows) regardless of list size, at cost of complexity (item heights, scroll restoration, accessibility, focus management).

9 min read·~15 min to think through

Two often-confused techniques:

  • Infinite scroll: load data incrementally as the user scrolls (next page when near bottom).
  • Virtualization (windowing): render only the DOM for visible rows; everything off-screen exists in JS memory but not the DOM.

You can have either, both, or neither.

Why virtualization matters

A list of 10,000 DOM nodes:

  • Initial render is slow (browser layout/paint for 10k elements).
  • Memory: each DOM node has overhead; 10k rows × complex content = hundreds of MB.
  • Scroll FPS drops because the browser keeps the entire DOM live.
  • Re-renders touch all items.

With virtualization, the DOM has ~20–50 rows at any time (visible viewport + buffer). Scrolling unmounts above-viewport rows and mounts new ones from below. DOM size is constant regardless of data size.

The model

ts
[ Total items: 10,000, item height: 50px → total height: 500,000px ]

┌─────────────── scrollable container (500px tall) ────────────┐
│ ┌─ inner spacer (height = 500,000px) ─┐                       │
│ │                                       │                      │
│ │       (offscreen — empty)             │                      │
│ │  ┌──────────────────────────────┐    │                      │
│ │  │ row 200 (translateY: 10000)  │    │                      │
│ │  │ row 201                       │    │                      │
│ │  │ ...                           │    │   ← visible window  │
│ │  │ row 210                       │    │                      │
│ │  └──────────────────────────────┘    │                      │
│ │       (offscreenempty)             │                      │
│ └───────────────────────────────────────┘                      │
└────────────────────────────────────────────────────────────────┘

The container has a giant inner spacer so the scrollbar is correct. The visible window is rendered at the right offset. On scroll, the visible-index range updates, and a small batch of rows mounts/unmounts.

Libraries

  • react-window — small, simple, fixed/variable heights.
  • TanStack Virtual (react-virtual) — modern, dynamic measurements, framework-agnostic, used by TanStack Table.
  • react-virtuoso — handles dynamic heights and infinite scroll out of the box; richer API.

Don't roll your own unless you have to. Edge cases (variable height, sticky headers, item resize, scroll restoration on back-nav) are gnarly.

Hard parts

1. Variable item heights. Fixed heights → trivial math. Variable heights → measure as items render, cache, and the scrollbar position needs to update as measurements come in. Libraries handle this; the UX cost is mild scroll jitter on first scroll-through.

2. Scroll restoration on back navigation. With virtualization, the row the user was looking at isn't in the DOM after a re-render. You need to persist the scroll offset (sessionStorage) and re-apply after data hydrates.

3. Accessibility. Screen reader users navigate by tab/arrow. If your virtualized list removes the focused row from the DOM, focus is lost. Mitigate: keep the focused item rendered even if scrolled out; use aria-rowcount/aria-rowindex so AT knows the total count.

4. Ctrl-F / find-on-page. Browser's native find only searches the DOM. With virtualization, only visible rows are findable. Workaround: server-side search, or a custom find UI.

5. CSS gotchas. Sticky headers inside virtualized containers, intersection-observer-based effects on rows, hover/tooltip state surviving unmount.

6. Combining with infinite scroll. When the user scrolls near the end of the rendered range, fetch the next page, append to data, and the virtualizer renders new rows. Handle loading sentinel, error, retry.

When to use what

DatasetStrategy
< 100 rowsPlain map(). Don't virtualize.
100–1000 rowsPlain rendering is usually fine; profile to confirm.
1000–10,000 rowsVirtualize. Pagination optional.
> 10,000 rowsVirtualize + infinite scroll / cursor pagination.
Variable heights, mixed mediaVirtualize with a library that measures dynamically (Virtuoso).

Infinite scroll trade-offs

Pro: low-friction discovery, no pagination UI.

Cons:

  • Footer becomes unreachable on long-scrolling pages.
  • Browser back returns to an empty list (no scroll restore without work).
  • Hard to share a deep link to a specific item.
  • Bot/SEO crawl issues (if SSR doesn't pre-render enough).

For lists where deep links and SEO matter, classic pagination or "load more" buttons may be better.

Putting it together

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

function List({ items, loadMore }) {
  const parentRef = useRef(null);
  const rowVirtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 50,
    overscan: 5,
  });

  useEffect(() => {
    const last = rowVirtualizer.getVirtualItems().at(-1);
    if (last && last.index >= items.length - 5) loadMore();
  }, [rowVirtualizer.getVirtualItems(), items.length]);

  return (
    <div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
      <div style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
        {rowVirtualizer.getVirtualItems().map(v => (
          <div
            key={v.key}
            style={{
              position: 'absolute',
              top: 0,
              transform: `translateY(${v.start}px)`,
              height: v.size,
              width: '100%',
            }}
          >
            <Row item={items[v.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

Follow-up questions

  • How do you handle variable-height items in a virtualized list?
  • How do you restore scroll position after navigating away and back?
  • What are the a11y trade-offs of virtualization?
  • How would you SSR a virtualized list?

Common mistakes

  • Virtualizing tiny lists — added complexity for no win.
  • Forgetting overscan — rows pop in visibly during fast scrolls.
  • Mismatched estimateSize and actual height — scrollbar jumps as items measure.
  • Lost focus when a focused row unmounts — keep the active item rendered.
  • Forgetting to memoize Row components — every scroll re-renders all visible rows.
  • Using infinite scroll where a paginated table would be better (SEO, deep links).

Performance considerations

  • Virtualization keeps DOM size constant — typically 60fps scroll on 100k-row lists. Memory drops from hundreds of MB to tens. CPU during scroll drops because layout only touches ~30 nodes. Infinite scroll keeps the network/render budget bounded as the user scrolls. Combined, you can render 'infinite' lists with near-constant cost.

Edge cases

  • Sticky group headers inside a virtualized list need custom logic — most libraries support them.
  • Window resize must trigger remeasurement.
  • Items containing iframes or media elements that have load cost — mount/unmount thrash is painful; consider pinning.
  • SSR: server renders only an estimated viewport; client hydrates and measures.
  • RTL languages and horizontal virtualization combine awkwardly — test.

Real-world examples

  • Slack message list: virtualized (scroll back through thousands of messages).
  • Twitter / X timeline: virtualized + infinite scroll.
  • Spreadsheet apps (Google Sheets, Airtable): row + column virtualization for massive grids.
  • Notion's database views.

Senior engineer discussion

Seniors should separate the *data* problem (infinite/paginated fetch) from the *render* problem (virtualization), and pick each independently. They know the a11y cost of virtualization (focus, find-on-page, screen readers) and design mitigations. For new work they reach for TanStack Virtual or Virtuoso rather than rolling their own.

Related questions