Optimize rendering for a list of 10,000 items
Don't render 10,000 nodes. Virtualize: render only the slice visible in the viewport (plus a small overscan). Use `@tanstack/react-virtual` or `react-window`. Stable keys, memoized row components, fixed or pre-measured heights, and CSS containment to keep paint cheap. Cursor-based pagination on the server side if data is unbounded.
Rendering 10k DOM nodes is the wrong baseline. A 10k-row table touches: 10k React fiber objects, 10k DOM nodes, 10k style recalculations, 10k layout boxes. Even at 50µs each, you blow your 16ms frame budget. The fix is virtualization (a.k.a. windowing): render only what's in the viewport.
The mental model.
┌──────────────┐ scroll container (overflow:auto), fixed height
│ ░░░░░░░░░░░░ │ spacer above — height = (firstVisibleIndex) × rowHeight
│ ░░░░░░░░░░░░ │
│ ┌──────────┐ │ visible rows (typically 10–30 at a time)
│ │ row 142 │ │
│ │ row 143 │ │
│ │ row 144 │ │
│ └──────────┘ │
│ ░░░░░░░░░░░░ │ spacer below — height = (totalCount − lastVisibleIndex) × rowHeight
└──────────────┘The total scroll range is preserved by the spacers; only ~20 rows ever exist in the DOM.
Use a battle-tested lib. Don't roll this yourself for production:
@tanstack/react-virtual— modern, headless, supports dynamic heights, RTL, horizontal lists. Default choice in 2026.react-window— simpler API, fixed/variable height, smaller bundle.react-virtuoso— feature-rich for chat-like reverse lists and grouped sections.
const parentRef = useRef<HTMLDivElement>(null);
const rowVirt = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40,
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: 600, overflow: "auto" }}>
<div style={{ height: rowVirt.getTotalSize(), position: "relative" }}>
{rowVirt.getVirtualItems().map(v => (
<Row key={items[v.index].id} item={items[v.index]} style={{
position: "absolute", top: 0, left: 0, transform: `translateY(${v.start}px)`,
height: v.size, width: "100%",
}} />
))}
</div>
</div>
);The supporting cast — virtualization alone isn't enough.
- Stable keys.
key={item.id}, not array index. Index keys force re-mount on scroll because the same DOM slot gets a different index each frame.
- Memoize the row component.
React.memowith a custom equality if rows take object props. Otherwise each scroll re-renders all visible rows.
- CSS containment.
contain: layout paint styleon the row. Tells the browser the row can be laid out and painted independently of its surroundings.
will-change: transformsparingly. Only on the transformed inner container, not on every row — promoted layers cost memory.
- Avoid heavy children. Inline editors, charts, expensive formatters in rows kill scroll perf. Render a cheap placeholder until the row is fully visible (intersection observer).
Dynamic row heights. TanStack Virtual measures rows after mount and adjusts. The scroll jumps from the estimate to the real height — keep estimates close to reality (run once, observe average) to minimize visual jolt.
The server side matters too. 10k items in memory is fine; 10M is not. Use cursor pagination + virtualization together — fetch pages of 100–500 on scroll, append to the array, virtualize the union.
When NOT to virtualize.
- Lists under ~200 items — overhead isn't worth it.
- Lists that need
Ctrl+Fbrowser search to work — virtualized rows aren't in the DOM until scrolled into view. (Workaround: native CSScontent-visibility: autokeeps DOM but skips layout/paint for off-screen content. Browser support is good in 2026.) - Print views, accessibility audits where the full DOM matters — render unvirtualized for those modes.
content-visibility: auto — the lazy alternative. For long pages with sections (not infinite lists), content-visibility: auto skips rendering off-screen blocks until they enter viewport. Cheaper to adopt than full virtualization, but doesn't reduce DOM node count.
Follow-up questions
- •How does TanStack Virtual measure dynamic row heights?
- •Trade-offs of `content-visibility: auto` vs virtualization?
- •Why does index-as-key break virtualization?
- •How would you implement bidirectional infinite scroll (history + new messages)?
Common mistakes
- •Rendering all 10k rows hidden via `display: none` — they still take fiber + DOM cost.
- •Using array index as key in a virtualized list.
- •Skipping memoization on the row component.
- •Estimates way off real heights, causing scroll jank.
Performance considerations
- •Aim for <16ms scroll handler cost — measure with Performance panel's scrolling profile.
- •Move expensive cell content (markdown rendering, chart) behind an IntersectionObserver.
- •Avoid layout-triggering CSS inside rows (e.g., reading `offsetHeight` during render).
Edge cases
- •Sticky headers — virtualizers need an explicit sticky-row API.
- •Selection across many rows — store selection by id, not by index.
- •Keyboard navigation — focus must move to off-screen rows before they're scrolled into view.
Real-world examples
- •Notion's table view, Linear's issue list, Slack's message history, Gmail inbox — all virtualized.