VirtualizationWindowingLarge ListsPerformancereact-window

List Virtualization

Learn how to render lists with 10,000+ items without destroying performance. Understand the windowing technique that keeps only visible items in the DOM — the standard answer to every 'how would you handle a large list?' interview question.

28 min read12 sections
01

Overview

Rendering a list of 10,000 items means creating 10,000 DOM nodes. Each node consumes memory, participates in layout calculations, and slows down every scroll event. The browser wasn't designed for this — the page freezes, scrolling stutters, and initial render takes seconds.

List virtualization (also called "windowing") solves this by rendering only the items currently visible in the viewport — typically 20–50 out of thousands. As the user scrolls, items entering the viewport are mounted and items leaving are unmounted. The DOM stays small, scrolling stays smooth, and memory stays low.

This technique is used by every major app that handles large datasets — Slack, Twitter, VS Code, Google Sheets. It's also one of the most common answers to "how would you optimize a large list?" in frontend interviews.

Why this matters

"How would you render 10,000 items efficiently?" is a standard frontend system design question. The answer is always virtualization. Knowing how it works internally — not just which library to use — is what separates strong candidates.

02

The Problem: Rendering Large Lists

Let's understand exactly why rendering thousands of items is slow. The problem isn't React — it's the browser's rendering pipeline.

📦

DOM Size

Each DOM node takes ~0.5–1KB of memory. 10,000 nodes = 5–10MB just for the DOM tree. The browser must track every node for layout, events, and accessibility.

🔨

Layout Thrashing

Every DOM change triggers style recalculation and layout. With 10,000 nodes, layout computation becomes the bottleneck — especially during scroll.

🎨

Paint & Composite

The browser must paint all visible layers and composite them. More DOM nodes = more layers to manage, more pixels to paint, more GPU memory consumed.

naive-large-list.jsxjavascript
// ❌ Naive approach: render ALL 10,000 items

function ProductList({ products }) {
  // products.length === 10,000
  return (
    <div className="list-container" style={{ height: "600px", overflow: "auto" }}>
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

// What happens:
// 1. React creates 10,000 Virtual DOM nodes         (~50ms)
// 2. React diffs and commits 10,000 DOM nodes       (~200ms)
// 3. Browser calculates layout for 10,000 elements  (~300ms)
// 4. Browser paints visible + offscreen elements     (~100ms)
//
// Total initial render: ~650ms (visible freeze)
// Scroll performance: janky (layout recalc on every frame)
// Memory: ~15MB for DOM alone
//
// The user sees a blank screen for nearly a second,
// then experiences choppy scrolling. Unacceptable.

The Numbers

Metric100 Items1,000 Items10,000 Items
DOM nodes~300~3,000~30,000
Initial render~15ms~120ms~650ms+
Memory (DOM)~0.3MB~2MB~15MB
Scroll FPS60fps30–45fps10–20fps
User experienceSmoothNoticeable lagUnusable

The key insight

The user can only see ~10–20 items at a time in a typical viewport. Rendering 10,000 items when only 20 are visible means 99.8% of the work is wasted. That wasted work is what virtualization eliminates.

03

What Is List Virtualization?

List virtualization is a technique where you only render the items currently visible in the scrollable viewport, plus a small buffer above and below. As the user scrolls, items entering the viewport are created and items leaving are destroyed. The total number of DOM nodes stays constant regardless of list size.

Virtualization Concepttext
Total items: 10,000
Viewport height: 600px
Item height: 50px
Visible items: 600 / 50 = 12 items
Overscan buffer: 5 items above + 5 below

DOM at any time: only ~22 items rendered (not 10,000)

┌─────────────────────────────────────┐
Items 14,988               │  ← Not rendered (above viewport)
│         (empty space via padding)   │
├─────────────────────────────────────┤
│  ▓▓▓  Item 4,989  (overscan)  ▓▓▓   │  ← Rendered (buffer above)
│  ▓▓▓  Item 4,990  (overscan)  ▓▓▓   │
│  ▓▓▓  Item 4,991  (overscan)  ▓▓▓   │
│  ▓▓▓  Item 4,992  (overscan)  ▓▓▓   │
│  ▓▓▓  Item 4,993  (overscan)  ▓▓▓   │
├═════════════════════════════════════┤
│  ███  Item 4,994  (visible)   ███   │  ← Viewport start
│  ███  Item 4,995  (visible)   ███   │
│  ███  Item 4,996  (visible)   ███   │
│  ███  Item 4,997  (visible)   ███   │
│  ███  Item 4,998  (visible)   ███   │
│  ███  Item 4,999  (visible)   ███   │
│  ███  Item 5,000  (visible)   ███   │
│  ███  Item 5,001  (visible)   ███   │
│  ███  Item 5,002  (visible)   ███   │
│  ███  Item 5,003  (visible)   ███   │
│  ███  Item 5,004  (visible)   ███   │
│  ███  Item 5,005  (visible)   ███   │
├═════════════════════════════════════┤
│  ▓▓▓  Item 5,006  (overscan)  ▓▓▓   │  ← Viewport end
│  ▓▓▓  Item 5,007  (overscan)  ▓▓▓   │
│  ▓▓▓  Item 5,008  (overscan)  ▓▓▓   │
│  ▓▓▓  Item 5,009  (overscan)  ▓▓▓   │
│  ▓▓▓  Item 5,010  (overscan)  ▓▓▓   │  ← Rendered (buffer below)
├─────────────────────────────────────┤
Items 5,01110,000          │  ← Not rendered (below viewport)
│         (empty space via padding)   │
└─────────────────────────────────────┘

Scrollbar works normally because the container has
the full height (10,000 × 50px = 500,000px).
Only ~22 DOM nodes exist at any time.
👁️

Viewport

The visible scrollable area. Only items within this rectangle need to be rendered. Typically shows 10–30 items depending on item height and container size.

📏

Overscan

Extra items rendered above and below the viewport as a buffer. Prevents blank flashes during fast scrolling. Usually 3–10 items in each direction.

📐

Spacer

Empty space (padding or a tall inner container) that gives the scrollbar the correct total height. The user sees a normal scrollbar for 10,000 items even though only 22 exist in the DOM.

Why it's called 'windowing'

You're looking at the data through a "window" — a fixed-size viewport that slides over the full list as you scroll. The window shows a small slice of the data at any time. The term "windowing" and "virtualization" are used interchangeably.

04

How Virtualization Works

Under the hood, virtualization is a scroll event handler that calculates which items are visible and renders only those. Here's the step-by-step process.

1

Listen to scroll events

The virtualized container listens for scroll events on the scrollable element. On each scroll, it reads the current scrollTop value.

2

Calculate visible range

Using scrollTop, container height, and item height, calculate which items fall within the viewport: startIndex = Math.floor(scrollTop / itemHeight), endIndex = startIndex + Math.ceil(containerHeight / itemHeight).

3

Add overscan buffer

Extend the range by a few items above and below (overscan). This prevents blank flashes during fast scrolling by pre-rendering items just outside the viewport.

4

Render only the visible slice

Map over items[startIndex..endIndex] and render them. Each item is absolutely positioned at its correct offset (top = index × itemHeight) within a container that has the full list height.

5

Recycle on scroll

As the user scrolls, the visible range shifts. Items leaving the range are unmounted, items entering are mounted. The DOM node count stays constant.

virtualization-core.jsxjavascript
// Simplified virtualization implementation

function VirtualList({ items, itemHeight, containerHeight }) {
  const [scrollTop, setScrollTop] = useState(0);

  // Total height of all items (for scrollbar)
  const totalHeight = items.length * itemHeight;

  // Calculate visible range
  const startIndex = Math.floor(scrollTop / itemHeight);
  const endIndex = Math.min(
    items.length - 1,
    Math.floor((scrollTop + containerHeight) / itemHeight)
  );

  // Add overscan (5 items above and below)
  const overscan = 5;
  const visibleStart = Math.max(0, startIndex - overscan);
  const visibleEnd = Math.min(items.length - 1, endIndex + overscan);

  // Build the visible items
  const visibleItems = [];
  for (let i = visibleStart; i <= visibleEnd; i++) {
    visibleItems.push(
      <div
        key={items[i].id}
        style={{
          position: "absolute",
          top: i * itemHeight,
          height: itemHeight,
          width: "100%",
        }}
      >
        <ItemComponent item={items[i]} />
      </div>
    );
  }

  return (
    <div
      style={{ height: containerHeight, overflow: "auto", position: "relative" }}
      onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
    >
      {/* Inner container with full height for scrollbar */}
      <div style={{ height: totalHeight, position: "relative" }}>
        {visibleItems}
      </div>
    </div>
  );
}

// 10,000 items but only ~22 DOM nodes at any time.
// Scroll performance is identical to a 22-item list.

The Positioning Trick

Positioning Strategytext
Outer container (scrollable):
┌──────────────────────────────────┐  height: 600px
│                                  │  overflow: auto
Inner container:                │
│  ┌────────────────────────────┐  │  height: 500,000px (10k × 50px)
│  │                            │  │  position: relative
│  │  (empty space above)       │  │
│  │                            │  │
│  │  ┌──────────────────────┐  │  │  position: absolute
│  │  │ Item 4994            │  │  │  top: 249,700px
│  │  ├──────────────────────┤  │  │
│  │  │ Item 4995            │  │  │  top: 249,750px
│  │  ├──────────────────────┤  │  │
│  │  │ Item 4996            │  │  │  top: 249,800px
│  │  ├──────────────────────┤  │  │
│  │  │ ...                  │  │  │
│  │  └──────────────────────┘  │  │
│  │                            │  │
│  │  (empty space below)       │  │
│  │                            │  │
│  └────────────────────────────┘  │
│                                  │
└──────────────────────────────────┘

Each item is absolutely positioned at (index × itemHeight).
The inner container's full height gives the scrollbar
the correct size and behavior.
The browser handles scrolling nativelyno JS animation needed.

Why absolute positioning?

Absolute positioning lets items be placed at exact pixel offsets without affecting each other's layout. When an item is added or removed from the rendered set, no other items need to reflow. This is critical for scroll performance — layout recalculation is the biggest bottleneck.

05

Without vs With Virtualization

The difference is dramatic. Here's a side-by-side comparison of rendering 10,000 items with and without virtualization.

Metric❌ Without Virtualization✅ With Virtualization
DOM nodes~30,000~60–80
Initial render650ms+~15ms
Memory (DOM)~15MB~0.1MB
Scroll FPS10–20fps60fps
Time to interactive1–3 secondsInstant
Works with 100k itemsBrowser crashesSame as 100 items
without-virtualization.jsxjavascript
// ❌ WITHOUT virtualization — all 10,000 items in the DOM

function MessageList({ messages }) {
  return (
    <div className="chat-container" style={{ height: 600, overflow: "auto" }}>
      {messages.map(msg => (
        <MessageBubble key={msg.id} message={msg} />
      ))}
    </div>
  );
}

// DOM tree:
// <div class="chat-container">
//   <div class="message">...</div>    ← item 1
//   <div class="message">...</div>    ← item 2
//   ...
//   <div class="message">...</div>    ← item 10,000
// </div>
//
// 10,000 DOM nodes. Browser is struggling.
with-virtualization.jsxjavascript
// ✅ WITH virtualization — only visible items in the DOM

import { FixedSizeList } from "react-window";

function MessageList({ messages }) {
  return (
    <FixedSizeList
      height={600}
      itemCount={messages.length}
      itemSize={60}
      width="100%"
    >
      {({ index, style }) => (
        <div style={style}>
          <MessageBubble message={messages[index]} />
        </div>
      )}
    </FixedSizeList>
  );
}

// DOM tree at any moment:
// <div class="virtual-container" style="height: 600px">
//   <div style="height: 600000px">   ← spacer for scrollbar
//     <div style="top: 29940px">...</div>  ← item 499
//     <div style="top: 30000px">...</div>  ← item 500
//     ...
//     <div style="top: 30600px">...</div>  ← item 510
//   </div>
// </div>
//
// ~20 DOM nodes. Browser is happy. Scrolling is butter-smooth.

Same scrollbar, different DOM

The user experience is identical — the scrollbar looks and behaves the same, scrolling feels natural, and all 10,000 items are accessible. The only difference is under the hood: 20 DOM nodes instead of 10,000. The browser doesn't know or care that most items don't exist in the DOM.

06

Libraries & Tools

You rarely need to build virtualization from scratch. These battle-tested libraries handle the hard parts — scroll synchronization, dynamic heights, keyboard navigation, and accessibility.

🪟

react-window

Lightweight (~6KB gzipped). Supports fixed and variable size lists and grids. The go-to choice for most use cases. Created by Brian Vaughn (React core team).

🎻

react-virtuoso

Feature-rich. Handles dynamic heights automatically, supports grouped lists, reverse scrolling (chat), and infinite loading out of the box. Slightly larger bundle.

Featurereact-windowreact-virtuosoTanStack Virtual
Bundle size~6KB~16KB~5KB
Fixed height items
Variable height items⚠️ (manual)✅ (auto-measured)✅ (auto-measured)
Grids
Reverse scroll (chat)⚠️ (manual)
Infinite loading⚠️ (addon)✅ (built-in)⚠️ (manual)
Framework agnostic❌ (React only)❌ (React only)
Learning curveLowLowMedium

react-window: Basic Usage

react-window-fixed.jsxjavascript
import { FixedSizeList } from "react-window";

// Fixed height items (simplest case)
function ProductList({ products }) {
  const Row = ({ index, style }) => (
    <div style={style} className="product-row">
      <span>{products[index].name}</span>
      <span>${products[index].price}</span>
    </div>
  );

  return (
    <FixedSizeList
      height={500}        // Container height
      width="100%"        // Container width
      itemCount={products.length}  // Total items
      itemSize={50}       // Each item is 50px tall
      overscanCount={5}   // Render 5 extra items above/below
    >
      {Row}
    </FixedSizeList>
  );
}
react-window-variable.jsxjavascript
import { VariableSizeList } from "react-window";

// Variable height items (e.g., chat messages)
function ChatMessages({ messages }) {
  const listRef = useRef(null);

  // Return height for each item
  const getItemSize = (index) => {
    const msg = messages[index];
    // Short messages: 50px, long messages: 100px, with images: 200px
    if (msg.image) return 200;
    if (msg.text.length > 100) return 100;
    return 50;
  };

  const Row = ({ index, style }) => (
    <div style={style}>
      <ChatBubble message={messages[index]} />
    </div>
  );

  return (
    <VariableSizeList
      ref={listRef}
      height={600}
      width="100%"
      itemCount={messages.length}
      itemSize={getItemSize}
      overscanCount={10}
    >
      {Row}
    </VariableSizeList>
  );
}

react-virtuoso: Auto-Measured Heights

react-virtuoso-example.jsxjavascript
import { Virtuoso } from "react-virtuoso";

// react-virtuoso measures item heights automatically
// No need to specify itemSize or getItemSize
function CommentFeed({ comments }) {
  return (
    <Virtuoso
      style={{ height: 600 }}
      totalCount={comments.length}
      itemContent={(index) => (
        <CommentCard comment={comments[index]} />
      )}
      // Built-in infinite loading
      endReached={() => loadMoreComments()}
      // Built-in loading indicator
      components={{
        Footer: () => <LoadingSpinner />,
      }}
    />
  );
}

// react-virtuoso handles:
// - Measuring each item's actual rendered height
// - Caching measurements for smooth scrolling
// - Adjusting scroll position when heights change
// - No manual height calculation needed

Which library to choose?

Use react-window for simple lists with known item heights — it's tiny and fast. Use react-virtuoso when items have dynamic/unknown heights, you need reverse scrolling (chat), or you want built-in infinite loading. Use TanStack Virtual if you need framework-agnostic support or complex grid layouts.

07

Real-World Example

Let's build a realistic product catalog with search, filtering, and 10,000 items. We'll start with the naive approach and optimize it.

❌ Naive: All Items Rendered

catalog-naive.jsxjavascript
function ProductCatalog() {
  const [search, setSearch] = useState("");
  const [products] = useState(() => generateProducts(10000));

  const filtered = useMemo(() =>
    products.filter(p =>
      p.name.toLowerCase().includes(search.toLowerCase())
    ),
    [products, search]
  );

  return (
    <div>
      <input
        value={search}
        onChange={e => setSearch(e.target.value)}
        placeholder="Search 10,000 products..."
      />
      <p>{filtered.length} results</p>

      {/* ❌ Renders ALL filtered items */}
      <div style={{ height: 600, overflow: "auto" }}>
        {filtered.map(product => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </div>
  );
}

function ProductCard({ product }) {
  return (
    <div className="product-card" style={{ height: 80, padding: 12 }}>
      <div className="font-semibold">{product.name}</div>
      <div className="text-gray-500">${product.price}</div>
      <div className="text-sm text-gray-400">{product.category}</div>
    </div>
  );
}

// Problems:
// - Initial render: ~800ms freeze
// - Typing in search: 200ms+ delay per keystroke
// - Scrolling: 15fps, visible jank
// - Memory: 20MB+ for DOM nodes

✅ Optimized: Virtualized List

catalog-virtualized.jsxjavascript
import { FixedSizeList } from "react-window";

function ProductCatalog() {
  const [search, setSearch] = useState("");
  const [products] = useState(() => generateProducts(10000));

  const filtered = useMemo(() =>
    products.filter(p =>
      p.name.toLowerCase().includes(search.toLowerCase())
    ),
    [products, search]
  );

  // Memoize the row renderer to prevent unnecessary re-renders
  const Row = useCallback(({ index, style }) => (
    <div style={style}>
      <ProductCard product={filtered[index]} />
    </div>
  ), [filtered]);

  return (
    <div>
      <input
        value={search}
        onChange={e => setSearch(e.target.value)}
        placeholder="Search 10,000 products..."
      />
      <p>{filtered.length} results</p>

      {/* ✅ Only renders visible items */}
      <FixedSizeList
        height={600}
        width="100%"
        itemCount={filtered.length}
        itemSize={80}
        overscanCount={5}
      >
        {Row}
      </FixedSizeList>
    </div>
  );
}

const ProductCard = React.memo(function ProductCard({ product }) {
  return (
    <div className="product-card" style={{ height: 80, padding: 12 }}>
      <div className="font-semibold">{product.name}</div>
      <div className="text-gray-500">${product.price}</div>
      <div className="text-sm text-gray-400">{product.category}</div>
    </div>
  );
});

// Results:
// - Initial render: ~15ms (instant)
// - Typing in search: <16ms per keystroke (no lag)
// - Scrolling: 60fps, butter-smooth
// - Memory: ~0.2MB for DOM nodes
// - Works identically with 100,000 items

What Changed

1

FixedSizeList replaces the .map() loop

Instead of rendering all 10,000 items, FixedSizeList renders only the ~12 visible items plus 5 overscan items above and below. Total: ~22 DOM nodes.

2

React.memo on ProductCard

Prevents individual cards from re-rendering when the list scrolls. Only cards entering the viewport are mounted; cards leaving are unmounted.

3

useCallback on the Row renderer

Stabilizes the row component reference so react-window doesn't recreate it on every parent render. This is important when the parent re-renders (e.g., on search).

4

useMemo on the filtered array

Prevents refiltering 10,000 items on every render. Only recomputes when the search query or products array changes.

The combined effect

Virtualization handles the DOM problem (too many nodes). React.memo handles the React problem (unnecessary re-renders). useMemo handles the computation problem (expensive filtering). Together, a 10,000-item list performs like a 20-item list.

08

Performance Insights

Virtualization is the most impactful single optimization for large lists. Here's how to get the most out of it and what to combine it with.

✓ Done

Constant DOM Size Regardless of Data

Whether you have 100 or 100,000 items, the DOM contains the same ~20–50 nodes. Initial render, scroll performance, and memory are all constant — O(1) relative to list size.

✓ Done

Combine with React.memo on List Items

Wrap individual list items in React.memo. When the list scrolls, items that stay in the viewport don't re-render. Only items entering/leaving the viewport mount/unmount.

✓ Done

Use Overscan to Prevent Blank Flashes

Render 3–10 extra items above and below the viewport. This gives the browser a buffer during fast scrolling so new items are ready before they become visible.

→ Could add

Combine with Pagination or Infinite Scroll

Don't load all 100,000 items upfront. Fetch pages of data as the user scrolls (infinite loading). Virtualization handles the DOM; pagination handles the network.

→ Could add

Use CSS contain for Paint Optimization

Add 'contain: layout style paint' to list items. This tells the browser each item is independent — changes inside one item don't affect others, enabling faster paint.

Virtualization vs Other Strategies

StrategyDOM NodesAll Data Available?Best For
Virtualization~20–50✅ Yes (in memory)Large lists with instant scroll access
Pagination~20–50❌ One page at a timeTables, search results, structured data
Infinite scrollGrows over time❌ Loaded progressivelyFeeds, timelines (combine with virtualization)
Load more buttonGrows on click❌ Loaded on demandSimple lists, user-controlled loading

Virtualization + infinite scroll = the gold standard

The best approach for large datasets is combining both: infinite scroll fetches data in pages as the user scrolls, and virtualization ensures only visible items are in the DOM. This is how Twitter, Slack, and Discord handle their feeds — unlimited data, constant DOM size, smooth scrolling.

09

Common Mistakes

Virtualization is powerful but has gotchas. These mistakes are common in implementations and come up in interviews.

🔬

Using virtualization for small lists

Virtualizing a list of 50 items adds complexity (library dependency, fixed container height, absolute positioning) with zero performance benefit. The overhead of scroll calculations may actually make it slower.

Only virtualize when the list is large enough to cause problems — typically 500+ items. For smaller lists, a simple .map() is fine. Profile first.

📏

Wrong item height assumptions

FixedSizeList requires an exact itemSize. If the actual rendered height doesn't match, items overlap or have gaps. This is especially common with dynamic content like text that wraps.

Measure actual item heights in the browser. For variable heights, use VariableSizeList (react-window) or react-virtuoso which auto-measures. Never guess heights.

📐

Not handling dynamic heights

Items with variable content (long text, images, expandable sections) don't fit a fixed height model. Using FixedSizeList for these causes content clipping or overflow.

Use VariableSizeList with a getItemSize function, or use react-virtuoso which measures heights automatically. Cache measurements for smooth scrolling.

🔑

Forgetting keys or using index keys

Virtualized lists mount and unmount items frequently as the user scrolls. Without stable keys, React can't reuse component instances correctly — state gets lost or attached to wrong items.

Always use unique, stable keys from your data (item.id). Never use array index — the index-to-item mapping changes constantly as the visible window shifts.

🖼️

Not accounting for container height

Virtualized lists need a fixed container height to calculate the viewport. If the container has height: auto or no explicit height, the library can't determine which items are visible.

Always set an explicit height on the virtualized container. Use CSS (height: 600px) or calculate it dynamically (window.innerHeight - headerHeight).

🔍

Breaking browser search (Ctrl+F)

Since most items aren't in the DOM, browser find (Ctrl+F) can't search through them. Users expect to find text in the list but only visible items are searchable.

Implement a custom search/filter UI that filters the data array. This is actually a better UX than Ctrl+F for large lists. Alternatively, some libraries support search integration.

10

Interview Questions

Virtualization comes up in nearly every frontend system design interview. These questions test both conceptual understanding and practical knowledge.

Q:What is list virtualization and why is it needed?

A: List virtualization (windowing) is a technique that renders only the items currently visible in the viewport, plus a small buffer. It's needed because rendering thousands of DOM nodes causes slow initial render, janky scrolling, and high memory usage. The browser's rendering pipeline (layout, paint, composite) scales with DOM size — virtualization keeps the DOM small regardless of data size.

Q:How does windowing work internally?

A: The virtualized container listens for scroll events and calculates which items fall within the viewport using scrollTop, container height, and item height. It renders only those items (plus overscan buffer), positioning each one absolutely at its correct offset. A tall inner container provides the full scrollable height for a natural scrollbar. As the user scrolls, items entering the viewport are mounted and items leaving are unmounted.

Q:What is overscan and why is it important?

A: Overscan is the extra items rendered above and below the visible viewport as a buffer. Without overscan, fast scrolling would show blank space before new items render. With 5-10 items of overscan, the browser has pre-rendered content ready as it scrolls into view. More overscan = smoother fast scrolling but more DOM nodes. It's a tunable trade-off.

Q:What is the difference between pagination and virtualization?

A: Pagination loads and shows one page of data at a time — the user clicks 'Next' to see more. Virtualization keeps all data in memory but only renders visible items in the DOM. Pagination reduces network load (fetch one page at a time). Virtualization reduces DOM load (render one viewport at a time). They solve different problems and can be combined: paginate the data fetching, virtualize the rendering.

Q:How would you handle variable height items in a virtualized list?

A: Three approaches: (1) Use VariableSizeList from react-window with a getItemSize function that returns each item's height. (2) Use react-virtuoso which auto-measures item heights by rendering them and reading offsetHeight. (3) Build a custom solution that measures items on mount, caches heights, and recalculates positions. Auto-measurement (option 2) is easiest; pre-calculated heights (option 1) give the smoothest scrolling.

Q:How would you render 100,000 items efficiently?

A: Combine three strategies: (1) Virtualization — only render visible items (~20-50 DOM nodes regardless of list size). (2) Infinite loading — don't fetch all 100k items upfront; load pages of 50-100 as the user scrolls. (3) React.memo on list items — prevent re-rendering items that stay in the viewport during state changes. This gives constant DOM size, progressive data loading, and minimal React work.

Q:What are the trade-offs of virtualization?

A: Benefits: constant DOM size, fast initial render, smooth scrolling, low memory. Trade-offs: (1) Ctrl+F browser search doesn't work for off-screen items. (2) Accessibility can be harder — screen readers may not see all items. (3) Added complexity — library dependency, fixed container height requirement, item height management. (4) Not worth it for small lists (<200 items). Always measure before adding virtualization.

Q:How do you handle scroll restoration in a virtualized list?

A: Most virtualization libraries support scrollToItem(index) or scrollToOffset(pixels). Save the scroll position (scrollTop or first visible item index) before navigation, then restore it when the user returns. react-window exposes scrollTo and scrollToItem methods via ref. For route-based restoration, save the position in session storage or a state manager keyed by the route.

11

Practice Section

Work through these scenarios to build practical intuition for when and how to apply virtualization.

1

How Would You Render 10,000 Items?

You're building a contact list for a CRM app. The API returns 10,000 contacts at once. Each contact card shows name, email, phone, and a small avatar. Users report the page takes 3 seconds to load and scrolling is choppy. How do you fix this?

Answer: Use list virtualization (react-window FixedSizeList or react-virtuoso). Set a fixed container height, render only visible contacts (~15-20 at a time), and add overscan of 5-10 items. Wrap ContactCard in React.memo to prevent re-renders during scroll. If the API response itself is slow, add pagination — fetch 100 contacts at a time with infinite scroll, and virtualize the rendered set.

2

When Would You NOT Use Virtualization?

Your team wants to add react-window to every list in the app 'for performance.' One list has 30 items, another has 80 items with simple text rows. Should you virtualize these?

Answer: No. Lists under ~200-500 simple items render fast enough without virtualization. Adding it introduces complexity: library dependency, fixed container height requirement, potential issues with dynamic heights, broken Ctrl+F search, and harder accessibility. The overhead of scroll event handling and position calculations may actually make small lists slower. Profile first — only virtualize when you measure a real problem.

3

How to Handle Dynamic Height Lists?

You're building a chat app where messages vary in length — some are one line, some are paragraphs with images. FixedSizeList doesn't work because items have different heights. What's your approach?

Answer: Use react-virtuoso — it automatically measures each item's rendered height and caches the measurements. It also supports reverse scrolling (newest messages at bottom) which is essential for chat. If you must use react-window, use VariableSizeList with a getItemSize function that estimates heights based on content length. Call resetAfterIndex when content changes to invalidate cached measurements.

4

Virtualization + Infinite Scroll

You're building a social media feed with potentially millions of posts. You can't load all posts at once, and you need smooth scrolling. How do you architect this?

Answer: Combine infinite scroll with virtualization: (1) Fetch posts in pages of 20-50 using React Query or SWR with cursor-based pagination. (2) Append new pages to a local array as the user scrolls near the bottom. (3) Render the array using react-virtuoso with its built-in endReached callback for triggering the next page fetch. (4) The DOM stays at ~30 nodes regardless of how many posts are loaded. This is exactly how Twitter and Instagram work.

5

Debugging Scroll Jank

You've implemented virtualization with react-window, but scrolling still feels janky. The list has 5,000 items with images. What could be wrong?

Answer: Likely causes: (1) Images loading during scroll cause layout shifts — add fixed dimensions to image containers and use lazy loading. (2) Overscan is too low — increase to 10-15 items so content is ready before scrolling into view. (3) Item components are expensive — wrap in React.memo and check for unnecessary re-renders with React DevTools. (4) Inline styles or objects created during render defeat memo — hoist constants. (5) The row renderer itself is recreated each render — wrap in useCallback.

12

Cheat Sheet (Quick Revision)

One-screen summary for quick revision before interviews.

Quick Revision Cheat Sheet

Virtualization: Render only visible items in the viewport. DOM stays small regardless of data size. Also called 'windowing.'

Why it's needed: 10,000 DOM nodes = slow render, janky scroll, high memory. 20 DOM nodes = instant render, 60fps scroll, low memory.

How it works: Listen to scroll → calculate visible range → render only that slice → position items absolutely → tall inner container for scrollbar.

Overscan: Extra items rendered above/below viewport as buffer. Prevents blank flashes during fast scroll. Usually 5–10 items.

react-window: Lightweight (~6KB). FixedSizeList for uniform heights, VariableSizeList for dynamic. Best for simple cases.

react-virtuoso: Auto-measures heights, reverse scroll (chat), built-in infinite loading. Best for dynamic content.

TanStack Virtual: Framework-agnostic, supports grids. Best for non-React or complex layouts.

When to virtualize: Lists with 500+ items, or fewer items that are expensive to render. Always measure first.

When NOT to virtualize: Lists under ~200 simple items. Adds complexity with no measurable benefit.

Combine with: React.memo (skip re-renders), useMemo (skip recomputation), infinite scroll (progressive loading).

Container height: Must be explicit (fixed px or calculated). height: auto breaks virtualization — library can't calculate viewport.

Ctrl+F limitation: Off-screen items aren't in DOM, so browser search can't find them. Build a custom search/filter UI instead.

Gold standard: Infinite scroll (fetch pages) + virtualization (render viewport) = unlimited data, constant DOM, smooth UX.