React Interview Prep
Performance Optimization

List Virtualization

Only Render What's Visible

LinkedIn Hook

You have a list of 10,000 items. You render all 10,000 into the DOM. The browser freezes for three seconds. The scroll stutters. The user leaves.

This happens in production more often than you think. E-commerce product feeds, admin dashboards with thousands of rows, chat applications with months of message history, analytics tables with raw data exports. The moment your list crosses a few hundred items, the DOM becomes the bottleneck.

The browser does not care that 9,950 of those items are off-screen. It still creates 10,000 DOM nodes, calculates 10,000 layouts, and holds 10,000 elements in memory. Your React code is fine. The DOM is the problem.

List virtualization solves this by only rendering the items currently visible in the viewport — usually 20 to 30 at a time. As the user scrolls, old items are removed and new items are added. The DOM never holds more than a small window of elements, no matter how large the dataset.

Libraries like react-window and react-virtualized make this trivial to implement. But interviewers do not just ask "what library do you use?" They ask "how does windowing work?" "What are the tradeoffs?" "How would you implement infinite scroll with virtualization?"

In this lesson, I break down the windowing concept, show you how react-window works with fixed and variable-size lists, explain the difference between react-window and react-virtualized, and build an infinite scroll implementation from scratch.

If you have ever rendered a long list and watched your app stutter — this lesson is the fix.

Read the full lesson -> [link]

#React #JavaScript #InterviewPrep #Frontend #Performance #Virtualization #WebDevelopment #CodingInterview #100DaysOfCode


List Virtualization thumbnail


What You'll Learn

  • Why rendering thousands of DOM nodes kills performance and what the browser actually does with each element
  • How the windowing concept works — rendering only visible items while faking the full scrollable height
  • How to use react-window for fixed-size and variable-size lists with practical code examples
  • The difference between react-window and react-virtualized and when to choose each
  • How to implement infinite scroll with virtualization for paginated data loading

The Concept — Render Less, Scroll More

Analogy: The Train Window

Imagine you are on a train traveling through a countryside with 10,000 houses along the track. You are sitting by the window. At any moment, you can only see about 10 houses through your window. The other 9,990 houses exist — they are out there along the track — but you are not looking at them, not photographing them, not processing them.

Now imagine a foolish train conductor who decides to build a miniature model of every single house and glue it to the inside of the train before departure, so you can "see them all at once." The train would be impossibly heavy. It would take hours to set up. And you would still only look at the 10 houses outside your window at any given moment.

That is what rendering 10,000 DOM nodes does. You build all 10,000 miniature houses (DOM elements), attach them to the train (the document), and the browser groans under the weight. The user still only sees 10 at a time.

List virtualization is the smart conductor. Instead of building all 10,000 models, he builds only the 10 houses you can currently see through the window. As the train moves forward, he quickly removes the houses that passed and builds the next ones coming into view. At any moment, there are only about 10 models on the train. The ride is smooth, the train is light, and you never notice the difference.

The key insight: the container maintains the full scrollable height (so the scrollbar looks correct), but only the visible slice of items actually exists in the DOM.


Why 10,000 DOM Nodes Is a Problem

Before jumping to the solution, you need to understand why the naive approach fails. When you render 10,000 list items, the browser must:

  1. Create 10,000 DOM nodes — each with its own memory allocation
  2. Calculate layout for all 10,000 — widths, heights, positions (reflow)
  3. Paint all 10,000 — even though most are off-screen, the browser still processes them
  4. Keep 10,000 nodes in memory — increasing garbage collection pressure
  5. Attach event listeners — if each row has click handlers, that is 10,000 listeners

The result: initial render takes 2-5 seconds, scrolling stutters, and memory usage spikes to hundreds of megabytes. On mobile devices, this can crash the browser tab entirely.


How Windowing Works

The windowing technique uses three key mechanics:

  1. A container with fixed height and overflow: auto — this creates the scrollable area
  2. An inner container with the total height of all items — this creates the correct scrollbar size (e.g., 10,000 items x 50px = 500,000px tall)
  3. Only the visible items rendered with absolute positioning — each item is positioned at its correct offset using top or transform

As the user scrolls, the library calculates which items are inside the visible window, removes items that scrolled out of view, and renders new items scrolling into view. The DOM never holds more than the visible items plus a small overscan buffer.


Code Example 1: The Problem — Rendering 10,000 Items Naively

import { useState, useEffect } from "react";

// Generate 10,000 items — simulates data from an API
function generateItems(count) {
  return Array.from({ length: count }, (_, index) => ({
    id: index,
    name: `User ${index + 1}`,
    email: `user${index + 1}@example.com`,
  }));
}

function NaiveList() {
  const [items] = useState(() => generateItems(10000));

  // Track how long the initial render takes
  useEffect(() => {
    console.log("Rendered all 10,000 items into the DOM");
    console.log("DOM node count:", document.querySelectorAll(".list-item").length);
  }, []);

  return (
    <div style={{ height: "500px", overflow: "auto" }}>
      {/* Every single item creates a DOM node — even the 9,950 you cannot see */}
      {items.map((item) => (
        <div key={item.id} className="list-item" style={{ padding: "10px", borderBottom: "1px solid #333" }}>
          <strong>{item.name}</strong> — {item.email}
        </div>
      ))}
    </div>
  );
}

// Output after mount:
// "Rendered all 10,000 items into the DOM"
// "DOM node count: 10000"
// Initial render: ~2-4 seconds on average hardware
// Memory usage: ~150-300 MB for complex list items

This is the baseline problem. Now let us fix it with virtualization.


Code Example 2: react-window — FixedSizeList

import { FixedSizeList } from "react-window";

// Generate the same 10,000 items
const items = Array.from({ length: 10000 }, (_, index) => ({
  id: index,
  name: `User ${index + 1}`,
  email: `user${index + 1}@example.com`,
}));

// Row component receives index and style from react-window
// The style prop contains the absolute positioning — you MUST apply it
function Row({ index, style }) {
  const item = items[index];

  return (
    <div style={{ ...style, padding: "10px", borderBottom: "1px solid #333", boxSizing: "border-box" }}>
      <strong>{item.name}</strong> — {item.email}
    </div>
  );
}

function VirtualizedList() {
  return (
    <FixedSizeList
      height={500}           // Visible viewport height in pixels
      width="100%"           // Width of the list container
      itemCount={10000}      // Total number of items in the dataset
      itemSize={50}          // Height of each row in pixels (must be fixed)
      overscanCount={5}      // Extra items rendered above and below the viewport for smooth scrolling
    >
      {Row}
    </FixedSizeList>
  );
}

// Output:
// Only ~15 DOM nodes in the list at any time (10 visible + 5 overscan above and below)
// Initial render: <50ms — instant
// Memory usage: ~5 MB regardless of dataset size
// Scrollbar behaves correctly — it reflects the full 10,000-item height

Key points: The style prop from react-window is mandatory — it contains position: absolute, top, and height values that position each row correctly within the scrollable container. Forgetting to spread style onto your row element is the most common mistake.


Code Example 3: react-window — VariableSizeList

import { VariableSizeList } from "react-window";

// Items with different content lengths — some rows need more height
const messages = Array.from({ length: 5000 }, (_, index) => ({
  id: index,
  sender: `User ${(index % 50) + 1}`,
  // Some messages are short, some are long
  text: index % 3 === 0
    ? "Hey!"
    : index % 3 === 1
    ? "Can you review the pull request I submitted yesterday? It has the new auth flow."
    : "This is a longer message that spans multiple lines and contains detailed information about the project timeline, deliverables, and team assignments for Q3.",
}));

// This function returns the height for each item based on its index
// You must estimate or pre-calculate the height of each row
function getItemSize(index) {
  const text = messages[index].text;
  if (text.length < 20) return 50;     // Short messages — single line
  if (text.length < 100) return 80;    // Medium messages — two lines
  return 120;                          // Long messages — three or more lines
}

function MessageRow({ index, style }) {
  const message = messages[index];

  return (
    <div style={{ ...style, padding: "12px 16px", borderBottom: "1px solid #2a2a2a", boxSizing: "border-box" }}>
      <div style={{ fontWeight: "bold", marginBottom: "4px" }}>{message.sender}</div>
      <div>{message.text}</div>
    </div>
  );
}

function ChatHistory() {
  return (
    <VariableSizeList
      height={600}               // Visible viewport height
      width="100%"               // Container width
      itemCount={messages.length} // Total messages
      itemSize={getItemSize}     // Function that returns height for each index
      overscanCount={3}          // Buffer items above and below
    >
      {MessageRow}
    </VariableSizeList>
  );
}

// Output:
// Only ~10-15 DOM nodes at any time despite 5,000 messages
// Rows with short messages take 50px, long messages take 120px
// Scrollbar position adjusts based on cumulative heights

When to use VariableSizeList: Chat applications, feeds with mixed content, tables where row height depends on content. The itemSize function must return a number for each index. If you cannot predict heights, you may need to measure them — libraries like react-virtualized-auto-sizer and react-window integrations can help.


Code Example 4: Infinite Scroll with Virtualization

import { FixedSizeList } from "react-window";
import { useState, useCallback, useRef } from "react";

// Simulates an API that returns pages of data
function fetchPage(page) {
  return new Promise((resolve) => {
    setTimeout(() => {
      const newItems = Array.from({ length: 50 }, (_, i) => ({
        id: page * 50 + i,
        name: `Product ${page * 50 + i + 1}`,
      }));
      resolve(newItems);
    }, 500); // Simulate network delay
  });
}

function InfiniteScrollList() {
  const [items, setItems] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  const pageRef = useRef(0);

  // Load the next page of data
  const loadMore = useCallback(async () => {
    if (isLoading || !hasMore) return;

    setIsLoading(true);
    const newItems = await fetchPage(pageRef.current);
    pageRef.current += 1;

    setItems((prev) => [...prev, ...newItems]);

    // Stop after 20 pages (1,000 items) — in real apps, the API signals when there is no more data
    if (pageRef.current >= 20) {
      setHasMore(false);
    }

    setIsLoading(false);
  }, [isLoading, hasMore]);

  // Detect when the user scrolls near the bottom
  // onItemsRendered gives us the range of currently visible items
  const handleItemsRendered = useCallback(
    ({ visibleStopIndex }) => {
      // When the user sees an item within 10 rows of the end, load more
      if (visibleStopIndex >= items.length - 10) {
        loadMore();
      }
    },
    [items.length, loadMore]
  );

  // Total item count includes a loading placeholder at the end
  const itemCount = hasMore ? items.length + 1 : items.length;

  function Row({ index, style }) {
    // If this index is beyond our loaded data, show a loading indicator
    if (index >= items.length) {
      return (
        <div style={{ ...style, padding: "10px", textAlign: "center", color: "#888" }}>
          Loading more...
        </div>
      );
    }

    const item = items[index];
    return (
      <div style={{ ...style, padding: "10px", borderBottom: "1px solid #333" }}>
        {item.name}
      </div>
    );
  }

  return (
    <FixedSizeList
      height={500}
      width="100%"
      itemCount={itemCount}
      itemSize={50}
      onItemsRendered={handleItemsRendered} // Callback fired when visible items change
    >
      {Row}
    </FixedSizeList>
  );
}

// Output:
// Initial load: first 50 items appear instantly
// Scrolling near bottom: "Loading more..." appears, then 50 new items load
// After 20 pages: 1,000 items loaded but only ~15 DOM nodes exist at any time
// Scrollbar grows gradually as more data loads

How it works: The onItemsRendered callback tells us which items are currently visible. When the visible range approaches the end of our loaded data, we fetch the next page. The list grows incrementally while the DOM stays small.


List Virtualization visual 1


List Virtualization visual 2


react-window vs react-virtualized

Both libraries are created by Brian Vaughn (bvaughn), a former React core team member. Here is when to use each:

react-window is the newer, lighter library (~6 KB gzipped). Use it when you need a simple virtualized list or grid. It covers 90% of use cases with a smaller bundle and simpler API.

react-virtualized is the older, heavier library (~33 KB gzipped). Use it when you need advanced features: multi-column sorting, auto-sizing cells, cell measurement cache, masonry layouts, or infinite loading built in. It is a full toolkit rather than a focused utility.

Interview answer: "I default to react-window because it is smaller and simpler. I only reach for react-virtualized if I need features that react-window does not provide, like auto-measured variable heights or masonry layouts."


Common Mistakes

Mistake 1: Forgetting to apply the style prop from react-window

// BAD: Row ignores the style prop — items stack on top of each other
function Row({ index }) {
  return (
    <div style={{ padding: "10px" }}>
      Item {index}
    </div>
  );
  // All items render at position 0 — they overlap
  // The list appears broken with only one visible item
}

// GOOD: Always spread the style prop — it contains position and offset
function Row({ index, style }) {
  return (
    <div style={{ ...style, padding: "10px" }}>
      Item {index}
    </div>
  );
  // Items are correctly positioned with absolute positioning
}

Mistake 2: Using wrong itemSize with FixedSizeList

// BAD: itemSize does not match actual rendered row height
<FixedSizeList
  height={500}
  itemCount={1000}
  itemSize={30}   // Declared height: 30px
>
  {({ index, style }) => (
    <div style={{ ...style, padding: "20px" }}>
      {/* Actual height: content + 40px padding = ~60px */}
      {/* Rows overlap because the list allocates only 30px per slot */}
      Item {index}
    </div>
  )}
</FixedSizeList>

// GOOD: itemSize must account for padding, borders, and margins
// Use box-sizing: border-box and match the total row height to itemSize
<FixedSizeList
  height={500}
  itemCount={1000}
  itemSize={60}   // Matches actual rendered height
>
  {({ index, style }) => (
    <div style={{ ...style, padding: "20px", boxSizing: "border-box" }}>
      Item {index}
    </div>
  )}
</FixedSizeList>

Mistake 3: Virtualizing a short list that does not need it

// BAD: Adding virtualization complexity for 20 items
// react-window adds overhead — container setup, absolute positioning, scroll handling
// For small lists, the overhead is MORE than just rendering all items
<FixedSizeList height={400} itemCount={20} itemSize={50}>
  {Row}
</FixedSizeList>

// GOOD: Just render the list normally — 20 items is trivial for the DOM
// Virtualization is worth it when you have hundreds or thousands of items
{items.map((item) => (
  <div key={item.id}>{item.name}</div>
))}

// Rule of thumb: consider virtualization when your list exceeds ~200-500 items
// Below that threshold, the DOM handles it fine

Interview Questions

Q: Why does rendering 10,000 items cause performance problems in React?

It is not a React problem — it is a DOM problem. React efficiently creates the virtual DOM for 10,000 elements, but the browser must create 10,000 actual DOM nodes, calculate their layout (reflow), paint them, and keep them in memory. Each DOM node consumes memory, and the browser processes all of them even when they are off-screen. The result is slow initial render (2-5 seconds), janky scrolling, and high memory usage. React's reconciliation is fast — the browser's DOM operations are the bottleneck.

Q: How does list virtualization (windowing) work under the hood?

Virtualization creates a scrollable container with the total height of all items (so the scrollbar looks correct), but only renders the items currently visible in the viewport. It uses absolute positioning to place each rendered item at its correct vertical offset. As the user scrolls, the library calculates which items are inside the visible window, unmounts items that scrolled out, and mounts items scrolling in. The DOM never holds more than the visible items plus a small overscan buffer — typically 10 to 30 elements regardless of dataset size.

Q: What is the difference between react-window and react-virtualized?

Both are by the same author. react-window is the newer, lighter library (~6 KB) with a focused API — FixedSizeList, VariableSizeList, FixedSizeGrid, VariableSizeGrid. It covers most use cases. react-virtualized is the older, heavier library (~33 KB) with additional features: auto-sizing cells, cell measurement cache, masonry layout, multi-grid, and built-in infinite loader. Choose react-window by default; use react-virtualized only when you need its advanced features.

Q: How would you implement infinite scroll with a virtualized list?

Use react-window's onItemsRendered callback, which reports which items are currently visible. When the visible range approaches the end of the loaded data (e.g., within 10 items of the last loaded index), trigger a fetch for the next page. Set itemCount to the loaded count plus one (for a loading placeholder). When new data arrives, update the state and the list renders the new items seamlessly. The combination of virtualization and pagination means you never have more than a few dozen DOM nodes even with thousands of loaded items.

Q: When should you NOT use list virtualization?

When the list is small (under 200-500 items), the DOM handles it without issues and virtualization adds unnecessary complexity. Also when items have unpredictable heights that cannot be estimated — VariableSizeList needs a size function, and inaccurate sizes cause layout glitches. When accessibility is critical and you need all items in the DOM for screen readers. When you need CSS-based search (Ctrl+F) to find off-screen items — virtualized items do not exist in the DOM, so browser find cannot locate them.


Quick Reference — Cheat Sheet

LIST VIRTUALIZATION
====================

The Problem:
  10,000 DOM nodes = slow render + janky scroll + high memory
  The browser processes ALL nodes even when off-screen
  React is not the bottleneck — the DOM is

The Solution (Windowing):
  Render ONLY visible items (~10-30 at a time)
  Fake the full height with an oversized inner container
  Use absolute positioning to place items at correct offsets
  Swap items in/out as the user scrolls

react-window (6 KB):
  FixedSizeList     — All rows same height (most common)
  VariableSizeList  — Rows with different heights
  FixedSizeGrid     — Uniform grid cells
  VariableSizeGrid  — Grid with varying row/column sizes

react-window API:
  height         — Viewport height in pixels
  width          — Viewport width
  itemCount      — Total items in dataset
  itemSize       — Row height (number or function for variable)
  overscanCount  — Buffer rows above/below visible area
  onItemsRendered — Callback with visible index range

Row Component Rules:
  MUST accept { index, style } props
  MUST spread style onto the root element
  style contains: position, top, height, width, left

Infinite Scroll Pattern:
  1. Use onItemsRendered to detect scroll position
  2. When visibleStopIndex nears items.length, fetch next page
  3. Set itemCount = items.length + 1 for loading indicator
  4. Append new items to state when fetch completes

+-------------------+-------------------+-------------------+
| Library           | Size (gzipped)    | Best For          |
+-------------------+-------------------+-------------------+
| react-window      | ~6 KB             | Simple lists/grids|
| react-virtualized | ~33 KB            | Advanced features |
| @tanstack/virtual | ~3 KB             | Headless, flexible|
+-------------------+-------------------+-------------------+

When to virtualize:    > 200-500 items
When NOT to:           Short lists, need Ctrl+F, accessibility-first
Overscan count:        3-5 is usually sufficient

Previous: Lesson 8.1 — Identifying Performance Problems -> Next: Lesson 8.3 — Code Splitting & Lazy Loading ->


This is Lesson 8.2 of the React Interview Prep Course — 10 chapters, 42 lessons.

On this page