Back to Performance
Performance
easy
mid

What is virtualization in the UI layer and when should you use it?

Virtualization (windowing) renders only the visible portion of a long list/grid, keeping DOM size constant regardless of dataset size. The container has a giant inner spacer for scrollbar correctness; only ~20-50 rows mount at any time. Libraries: react-window, TanStack Virtual, react-virtuoso. Trade-offs: a11y (focus, find-in-page), scroll restoration, sticky headers, dynamic item heights. Use for >1000 rows; for smaller lists it's overhead.

8 min read·~10 min to think through

Virtualization (also called windowing) is the technique of rendering only the visible window of items in a long list, regardless of how many items the dataset contains.

Why

A list of 10,000 DOM nodes:

  • Initial render: layout + paint for all 10k → seconds.
  • Memory: tens to hundreds of MB.
  • Scroll: every frame touches the whole DOM.
  • Re-render: all items recompute.

Virtualized:

  • DOM has ~20–50 nodes at any time.
  • Memory: small + bounded.
  • Scroll: only the few visible mount/unmount.
  • Re-render: only visible items.

The model

The container is the viewport (fixed height, scrollable). Inside, a single inner element has the total height (items.length × itemHeight) — that makes the scrollbar match a "full" list. Inside that spacer, only the currently-visible window of items is rendered, absolutely positioned at the correct top offset.

ts
container (height: 600px, overflow: auto)
└─ inner (height: 10000 * 50 = 500000px)
   └─ row 200 at top: 10000px
   └─ row 201 at top: 10050px
   └─ ... (visible window only)
   └─ row 211 at top: 10550px

On scroll, compute which rows are in view, mount those, unmount the rest.

Example with TanStack Virtual

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

function VirtualList({ items }) {
  const parentRef = useRef(null);
  const v = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 50,
    overscan: 5,            // render 5 extra rows above/below for smooth scroll
  });

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

Libraries

LibraryStrengths
react-windowSmall, simple, fixed and variable heights.
TanStack VirtualFramework-agnostic, dynamic measurement, modern API.
react-virtuosoExcellent for chat / dynamic heights / infinite scroll.

Hard parts

Variable item heights: easiest if known upfront. Dynamic measurement (Virtuoso, TanStack) measures rows as they render and updates the spacer — the scrollbar can shift slightly during the first scroll pass.

Scroll restoration: when navigating away and back, the focused row may not be in the DOM. Persist the scroll offset (sessionStorage) and re-apply after data hydrates.

Sticky headers / group sections: most libraries support sticky rows; you mark a row as sticky and the library keeps it pinned during scroll.

Accessibility:

  • aria-rowcount on the container so screen readers know the total.
  • Focus management: if the focused row scrolls out, decide between unmounting (focus lost) or pinning (DOM kept).
  • Keyboard navigation: arrow keys should scroll the list, not the page.

Find-in-page (Ctrl-F): browser's native find only searches DOM. Virtualized rows aren't findable. Workarounds: custom search UI, or render the searchable text in an off-screen visually-hidden element.

Animations: row mount/unmount can flash. Use overscan to mount before scrolling reveals the row.

Grid (2D) virtualization: harder. TanStack Virtual and react-window both support it; column virtualization is independent of row.

When to virtualize

Dataset sizeVerdict
< 100Plain map. Don't virtualize.
100–1000Probably fine. Profile first.
1000–10,000Virtualize.
> 10,000Virtualize + paginate / cursor-fetch new data.

Pitfalls

  • Mounting heavy children inside each row — every scroll mounts/unmounts → expensive. Memoize and keep row components light.
  • Forgetting overscan — rows pop in visibly during fast scrolls.
  • Mismatched estimateSize vs actual — scrollbar jumps.
  • Lost focus when the focused row unmounts.
  • Trying to use Ctrl-F in production and finding only visible rows.
  • Stacking virtualization inside virtualization (rare but possible) — gets weird.

When not

  • Small lists.
  • Lists where every row needs SEO indexing (Google crawler won't run scroll JS).
  • Lists where Ctrl-F is critical (settings list, command palette).

Mental model

Virtualization keeps DOM size constant. Pay a small complexity cost (library, a11y considerations, scroll restoration) to make 100k-row lists smooth. It's the standard solution for "scale" lists in modern apps.

Follow-up questions

  • How do you handle variable item heights?
  • What are the a11y implications of virtualization?
  • How do you restore scroll position on back-navigation?
  • When does virtualization break down (and what to use instead)?

Common mistakes

  • Virtualizing tiny lists — added complexity for no win.
  • No overscan — visible row pop-in.
  • Heavy children inside row — mount/unmount is expensive.
  • Mismatched estimateSize and actual heights — jumpy scrollbar.
  • Losing focus when the focused row unmounts.
  • Forgetting Ctrl-F doesn't work — UX surprise.

Performance considerations

  • Virtualization is one of the highest-ROI optimizations for long-list UIs. Memory drops from hundreds of MB to a few; scroll FPS hits 60 even on slow devices; CPU during scroll is constant. Cost is library + complexity. Net win for any list >1000 items.

Edge cases

  • Window resize → re-measure heights.
  • RTL languages flip horizontal virtualization math.
  • Sticky group headers within virtualized list — library-specific.
  • Items containing iframes / media — pin to avoid mount/unmount thrash.
  • SSR: render an estimated viewport on the server, hydrate on the client.

Real-world examples

  • Slack message list, Gmail inbox, Spotify track list.
  • Twitter / X timeline (infinite + virtualized).
  • Notion / Airtable databases.
  • Spreadsheets — 2D virtualization for both rows and columns.

Senior engineer discussion

Seniors pick a battle-tested library, accept the a11y trade-offs, and design around them (focus pinning, custom search). They distinguish virtualization (rendering) from pagination (data fetching) and combine when needed. They also know when not to virtualize — small lists, SEO-critical lists.

Related questions