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.
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
container (height: 600px, overflow: auto)
↓
inner spacer (height: items.length * itemHeight, e.g. 500000px)
↓
visible window of ~20 rows, absolutely positioned at correct top offsetOn scroll, compute which indices are visible based on scrollTop, mount those, unmount the rest. DOM size stays constant.
Libraries
| Library | Notes |
|---|---|
| react-window | Minimal, fixed and variable heights, good for simple lists |
| @tanstack/react-virtual | Modern, dynamic measurement, framework-agnostic |
| react-virtuoso | Excellent 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:
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-rowcountandaria-rowindexso 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
| Dataset | Verdict |
|---|---|
| < 100 items | Plain map. |
| 100-1000 | Plain rendering is usually fine; profile. |
| 1000-10,000 | Virtualize. |
| > 10,000 | Virtualize + 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.