Back to Performance
Performance
medium
senior

How would you optimize rendering for a list with ten thousand 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.

7 min read·~20 min to think through

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.

ts
┌──────────────┐ scroll container (overflow:auto), fixed height
│ ░░░░░░░░░░░░ │ spacer above — height = (firstVisibleIndex) × rowHeight
│ ░░░░░░░░░░░░ │
│ ┌──────────┐ │ visible rows (typically 1030 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.
tsx
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.

  1. 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.
  1. Memoize the row component. React.memo with a custom equality if rows take object props. Otherwise each scroll re-renders all visible rows.
  1. CSS containment. contain: layout paint style on the row. Tells the browser the row can be laid out and painted independently of its surroundings.
  1. will-change: transform sparingly. Only on the transformed inner container, not on every row — promoted layers cost memory.
  1. 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+F browser search to work — virtualized rows aren't in the DOM until scrolled into view. (Workaround: native CSS content-visibility: auto keeps 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.

Related questions