Back to Performance
Performance
easy
mid

How do you render very large lists efficiently with windowing and virtualization?

Same technique as virtualization — render only visible items, not the full N. Container has a giant inner spacer for scrollbar correctness; ~20-50 DOM rows at any time. Libraries: react-window (simple), TanStack Virtual (modern), react-virtuoso (chat / dynamic heights). DOM size becomes constant regardless of dataset. Trade-offs: variable heights, scroll restoration, a11y (focus, Ctrl-F), sticky headers. Use for >1000 items; combine with infinite scroll / cursor pagination for huge datasets.

7 min read·~10 min to think through

Big-list rendering breaks at scale because DOM size grows linearly with the dataset: 10k rows = slow initial render, hundreds of MB of memory, scroll jank, expensive re-renders.

Windowing (a.k.a. virtualization) renders only the visible slice of the list and uses a spacer to maintain correct scrollbar geometry.

The mechanism

ts
container (height: 600px, overflow: auto)

inner spacer (height: items.length * itemHeight, e.g. 500000px)

visible window of ~20 rows, absolutely positioned at correct top offset

On scroll, compute which indices are visible based on scrollTop, mount those, unmount the rest. DOM size stays constant.

Libraries

LibraryNotes
react-windowMinimal, fixed and variable heights, good for simple lists
@tanstack/react-virtualModern, dynamic measurement, framework-agnostic
react-virtuosoExcellent for chat-style + dynamic heights + infinite scroll

Rolling your own works but is gnarly — variable heights, scroll restoration, sticky headers, a11y, RTL, window resize all need handling.

Combining with infinite scroll

Windowing handles render cost. Infinite scroll handles data cost — fetch the next page as the user nears the end:

tsx
const virtualizer = useVirtualizer({ count: items.length, ... });

useEffect(() => {
  const last = virtualizer.getVirtualItems().at(-1);
  if (last && last.index >= items.length - 10 && hasMore) {
    fetchNextPage();
  }
}, [virtualizer.getVirtualItems(), items.length, hasMore]);

Variable heights

Two strategies:

Estimated + measured: virtualizer guesses size, measures on render, updates the spacer. Slight scrollbar jitter on first scroll-through; accepted by users.

Pre-measured: if you know all heights up front, pass an array. Smooth from the start.

Sticky / pinned items

Most libraries support sticky rows (group headers). Keep the focused item pinned if it would scroll out so focus isn't lost.

Accessibility

  • aria-rowcount and aria-rowindex so screen readers know total count.
  • Focus management: keep the active row mounted even if scrolled out, or pin it.
  • Keyboard navigation: arrow keys scroll the list, not the page.
  • Ctrl-F native find only searches DOM — virtualized rows are invisible to it. Custom search UI or hidden full-text index for crawlers/find.

Performance properties

  • DOM size: constant (~50 nodes regardless of dataset).
  • Memory: bounded.
  • Scroll FPS: 60 even on slow devices.
  • Re-render: only visible rows.

Pitfalls

  • Virtualizing tiny lists (< 100): overhead > benefit.
  • No overscan: visible row pop-in during fast scrolls.
  • Heavy children in rows: mount/unmount thrash.
  • Mismatched estimateSize: jumpy scrollbar.
  • Lost focus when active row unmounts.
  • Forgetting Ctrl-F users will be confused.
  • Animating row mount/unmount: visible flash.

When to use

DatasetVerdict
< 100 itemsPlain map.
100-1000Plain rendering is usually fine; profile.
1000-10,000Virtualize.
> 10,000Virtualize + cursor pagination.

Real-world examples

  • Slack message list.
  • Gmail inbox.
  • Twitter / X timeline.
  • Spotify track list.
  • Notion / Airtable database views.
  • Spreadsheets (2D virtualization).

Mental model

Windowing trades a bit of complexity (library + a11y considerations) for the ability to render lists of any size at constant cost. It's the standard solution for any list >1000 items in modern web apps.

Follow-up questions

  • How do you handle variable-height items?
  • What are the a11y implications?
  • How do you restore scroll on back-navigation?
  • When should you NOT virtualize?

Common mistakes

  • Virtualizing tiny lists — added complexity without payoff.
  • No overscan — visible pop-in.
  • Heavy children in rows — mount/unmount expensive.
  • Forgetting scroll restoration — broken back nav UX.
  • Lost focus when active row scrolls out.
  • No keyboard navigation hook-up.

Performance considerations

  • DOM constant regardless of dataset; memory bounded; 60fps scroll on slow devices. The complexity cost is real (libraries, a11y, edge cases), but the perf cost without it is fatal at scale.

Edge cases

  • Window resize requires re-measurement.
  • RTL languages flip horizontal math.
  • Items containing iframes / media that have load cost — pin to avoid mount thrash.
  • SSR: render estimated viewport on server, hydrate on client.
  • Mixed-height items with rapid scrolling — measurement lags behind, scrollbar jumps.

Real-world examples

  • Slack, Gmail, Spotify, Twitter — all virtualized lists.
  • Notion, Airtable, Linear — virtualized database/issue tables.
  • TanStack Table uses TanStack Virtual for massive grids.

Senior engineer discussion

Seniors reach for a battle-tested library (TanStack Virtual, Virtuoso), accept the a11y tradeoffs and design mitigations, and combine windowing with cursor pagination for genuinely unbounded data. They distinguish render cost (windowing) from data cost (pagination) and address both.

Related questions