ReactPerformanceScroll EventsDOM OptimizationLarge Datasets

Build a Virtualized List

Learn how to render 10,000+ items efficiently by only mounting the rows visible in the viewport. A critical performance technique tested in frontend interviews at top companies.

30 min read8 sections
01

Problem Statement

Build a virtualized list that renders 10,000 items efficiently. Instead of mounting all 10,000 DOM nodes, only render the rows currently visible in the scrollable viewport.

  • Render a scrollable list of 10,000 items
  • Only mount DOM nodes for visible rows (+ a small buffer)
  • Each row has a fixed height (e.g., 60px)
  • Scrollbar should reflect the full list length
  • Smooth scrolling with no visible flicker
  • Show stats: total items vs. rendered DOM nodes

Why this question?

Virtualization is a core performance technique used in every major app that displays large lists — Slack, Twitter, VS Code, Google Sheets. It tests your understanding of the DOM, scroll mechanics, and the cost of rendering. Interviewers love it because it's practical and has clear measurable impact.

02

Why Virtualization?

Rendering 10,000 DOM nodes is expensive. Each node costs memory, triggers layout calculations, and slows down paint. Virtualization solves this by only rendering what the user can see.

🐌

Without Virtualization

10,000 DOM nodes mounted at once. Slow initial render (1-3 seconds), high memory usage, janky scrolling. The browser has to lay out and paint all nodes.

With Virtualization

~15-20 DOM nodes mounted at any time. Instant render, low memory, smooth 60fps scrolling. Only visible rows exist in the DOM.

MetricNaive (10K nodes)Virtualized (~20 nodes)
Initial render1-3 seconds< 10ms
DOM nodes10,000~20
MemoryHighMinimal
Scroll FPSJanky (10-30fps)Smooth (60fps)

The golden rule

If the user can only see 10 items at a time, there's no reason to have 10,000 DOM nodes. Virtualization is the technique of keeping the DOM small while making the scrollbar behave as if all items exist.

03

How It Works

The trick is two nested divs: an outer scrollable container and an inner div sized to the full list height. You position only the visible items inside the inner div using absolute positioning.

Visual structuretext
┌─ Outer container (overflow: auto, height: 500px) ──┐
│                                                     │
│  ┌─ Inner div (height: 10000 * 60 = 600,000px) ──┐ │
│  │                                                │ │
│  │  (empty space abovescrolled past)           │ │
│  │                                                │ │
│  │  ┌──────────────────────────────────────────┐  │ │
│  │  │ Row 42  (position: absolute, top: 2520)  │  │ │
│  │  │ Row 43  (position: absolute, top: 2580)  │  │ │
│  │  │ Row 44  (position: absolute, top: 2640)  │  │ │  ← Only these
│  │  │ Row 45  (position: absolute, top: 2700)  │  │ │    are in the DOM
│  │  │ Row 46  (position: absolute, top: 2760)  │  │ │
│  │  │ ...                                      │  │ │
│  │  └──────────────────────────────────────────┘  │ │
│  │                                                │ │
│  │  (empty space belownot scrolled to yet)     │ │
│  │                                                │ │
│  └────────────────────────────────────────────────┘ │
│                                                     │
│  ═══════════ scrollbar ═══════════                   │
└─────────────────────────────────────────────────────┘
1

Outer container creates the viewport

A div with overflow: auto and a fixed height (e.g., 500px). This is what the user sees and scrolls.

2

Inner div creates the scrollbar

A div with height = totalItems × itemHeight. This makes the scrollbar the correct size even though most of the space is empty.

3

Visible items are absolutely positioned

Only the items in the viewport (plus a buffer) are rendered. Each is positioned with top: index × itemHeight so it appears in the right place.

04

The Math Behind It

The core of virtualization is simple arithmetic. Given the scroll position, calculate which items are visible.

Core calculationstypescript
const ITEM_HEIGHT = 60;       // Fixed height per row
const CONTAINER_HEIGHT = 500; // Visible viewport height
const TOTAL_ITEMS = 10_000;

// Total scrollable height (makes scrollbar correct)
const totalHeight = TOTAL_ITEMS * ITEM_HEIGHT;
// → 600,000px

// First visible item index
const startIndex = Math.floor(scrollTop / ITEM_HEIGHT);
// scrollTop = 2520 → startIndex = 42

// How many items fit in the viewport
const visibleCount = Math.ceil(CONTAINER_HEIGHT / ITEM_HEIGHT);
// 500 / 60 → 9 items visible

// Last visible item index
const endIndex = startIndex + visibleCount;
// 42 + 9 = 51

// Slice only the visible items
const visibleItems = items.slice(startIndex, endIndex);
VariableFormulaPurpose
totalHeightitems.length × ITEM_HEIGHTSets inner div height for correct scrollbar
startIndexMath.floor(scrollTop / ITEM_HEIGHT)First visible row
visibleCountMath.ceil(CONTAINER_HEIGHT / ITEM_HEIGHT)How many rows fit in viewport
topindex × ITEM_HEIGHTAbsolute position of each rendered row

Why Math.floor for start, Math.ceil for count?

Math.floor for startIndex because we want the item that's partially scrolled into view (round down). Math.ceil for visibleCount because a partially visible row at the bottom still needs to be rendered (round up).

05

Scroll Handling

The scroll handler reads scrollTop from the container and triggers a re-render with the new visible window.

Scroll handlertypescript
const [scrollTop, setScrollTop] = useState(0);

const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
  setScrollTop(e.currentTarget.scrollTop);
};

// On the container:
<div
  onScroll={handleScroll}
  style={{ height: CONTAINER_HEIGHT, overflow: "auto" }}
>
  {/* ... */}
</div>

State vs. ref for scrollTop?

Using useState is the simplest approach — it triggers a re-render on every scroll, which recalculates visible items. For most cases this is fast enough because you're only rendering ~20 items. If you need more performance, you can use useRef + requestAnimationFrame to batch scroll updates. Mention both in interviews.

06

Buffer & Edge Cases

A buffer renders extra items above and below the viewport to prevent white flashes during fast scrolling.

Adding a buffertypescript
const BUFFER = 5; // Render 5 extra items above and below

const startIndex = Math.floor(scrollTop / ITEM_HEIGHT);
const visibleCount = Math.ceil(CONTAINER_HEIGHT / ITEM_HEIGHT);

// Clamp to valid array bounds
const startIndex = Math.max(0, startIndex - BUFFER);
const endIndex = Math.min(
  ITEMS.length,
  startIndex + visibleCount + BUFFER
);

const visibleItems = ITEMS.slice(startIndex, endIndex);

// When rendering, use the original index for positioning:
visibleItems.map((item, i) => {
  const actualIndex = startIndex + i;
  return (
    <div
      key={item.id}
      style={{
        position: "absolute",
        top: actualIndex * ITEM_HEIGHT,
        height: ITEM_HEIGHT,
        width: "100%",
      }}
    >
      {/* row content */}
    </div>
  );
});
Edge CaseSolution
Scroll to topMath.max(0, startIndex - BUFFER) prevents negative indices
Scroll to bottomMath.min(ITEMS.length, ...) prevents out-of-bounds
Fast scrolling flickerBuffer of 5-10 items above/below ensures rows are ready before they scroll into view
Empty listCheck items.length === 0 and show an empty state

Why not just use overflow: hidden on the buffer?

The buffer items are outside the visible viewport but still in the DOM. They're hidden by the outer container's overflow: auto. When the user scrolls, these pre-rendered items slide into view instantly instead of being created from scratch — that's what prevents the flicker.

07

Full Implementation

Here's the complete virtualized list. Study how the scroll handler, index calculations, and absolute positioning work together.

virtualized-list/page.tsxtypescript
"use client";

import { useState } from "react";

interface ListItem {
  id: number;
  name: string;
  email: string;
  role: string;
}

const ROLES = [
  "Engineer", "Designer", "Product Manager",
  "Data Scientist", "DevOps", "QA Engineer",
];

const ITEMS: ListItem[] = Array.from({ length: 10_000 }, (_, i) => ({
  id: i + 1,
  name: `User ${i + 1}`,
  email: `user${i + 1}@example.com`,
  role: ROLES[i % ROLES.length],
}));

const ITEM_HEIGHT = 60;
const CONTAINER_HEIGHT = 500;
const BUFFER = 5;

export default function VirtualizedListPage() {
  const [scrollTop, setScrollTop] = useState(0);

  // Calculate visible window
  const startIndex = Math.floor(scrollTop / ITEM_HEIGHT);
  const visibleCount = Math.ceil(CONTAINER_HEIGHT / ITEM_HEIGHT);
  const startIndex = Math.max(0, startIndex - BUFFER);
  const endIndex = Math.min(
    ITEMS.length,
    startIndex + visibleCount + BUFFER
  );
  const visibleItems = ITEMS.slice(startIndex, endIndex);

  const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
    setScrollTop(e.currentTarget.scrollTop);
  };

  return (
    <div>
      {/* Stats */}
      <div>
        Total: {ITEMS.length.toLocaleString()} |
        Rendered: {visibleItems.length}
      </div>

      {/* Scrollable container */}
      <div
        onScroll={handleScroll}
        style={{
          height: CONTAINER_HEIGHT,
          overflow: "auto",
          position: "relative",
        }}
      >
        {/* Inner div — full height for scrollbar */}
        <div
          style={{
            height: ITEMS.length * ITEM_HEIGHT,
            position: "relative",
          }}
        >
          {/* Only visible items rendered */}
          {visibleItems.map((item, i) => {
            const actualIndex = startIndex + i;
            return (
              <div
                key={item.id}
                style={{
                  position: "absolute",
                  top: actualIndex * ITEM_HEIGHT,
                  height: ITEM_HEIGHT,
                  width: "100%",
                }}
              >
                <div>{item.name}</div>
                <div>{item.email} · {item.role}</div>
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
}
✓ Done

Minimal DOM nodes

Only ~20 DOM nodes exist at any time instead of 10,000. Reduces memory usage and layout/paint cost dramatically.

✓ Done

Correct scrollbar behavior

The inner div's height (totalItems × itemHeight) gives the browser the correct scrollbar size, even though most of the space is empty.

✓ Done

Buffer for smooth scrolling

5 extra items above and below the viewport are pre-rendered to prevent white flashes during fast scrolling.

→ Could add

Variable row heights

For rows with different heights, maintain a cumulative height array. Use binary search to find the startIndex from scrollTop. Libraries like react-virtuoso handle this.

→ Could add

requestAnimationFrame throttling

Wrap the scroll handler in rAF to batch updates to the next paint frame. Prevents unnecessary re-renders during fast scrolling.

08

Common Interview Follow-up Questions

After building the virtualized list, interviewers dig into performance nuances and real-world scenarios:

Q:How would you handle variable row heights?

A: Maintain an array of cumulative heights. To find startIndex, binary search the array for the scrollTop value. To position a row, use the cumulative height at its index. Measure row heights after render using refs or ResizeObserver. Libraries like react-virtuoso and react-window (VariableSizeList) handle this.

Q:Why use absolute positioning instead of translateY?

A: Both work. Absolute positioning with top is simpler and more intuitive. translateY is GPU-accelerated and avoids layout reflow, which can be faster for very large lists. In practice, the difference is negligible for virtualized lists since you're only rendering ~20 items. Mention both approaches.

Q:How would you add infinite scroll to a virtualized list?

A: Watch the endIndex. When it approaches the end of the loaded data, trigger a fetch for the next page. Append new items to the array. The virtualization logic stays the same — it just operates on a growing array. Use a loading indicator at the bottom.

Q:What about horizontal virtualization (like a spreadsheet)?

A: Apply the same technique on the X axis. Calculate visible columns from scrollLeft. Position cells with left: colIndex × colWidth. For a full grid (rows + columns), you virtualize both axes — this is how Google Sheets works.

Q:How do you handle scroll restoration (e.g., navigating back)?

A: Save the scrollTop value before navigation (in state, context, or sessionStorage). On mount, restore it by setting container.scrollTop = savedValue. The virtualization logic will automatically calculate the correct visible window.

Q:Why not just use CSS content-visibility: auto?

A: content-visibility: auto tells the browser to skip rendering off-screen elements. It's simpler but less predictable — the browser decides what to skip, and it still creates all DOM nodes. Virtualization gives you full control and guarantees minimal DOM nodes. Mention content-visibility as a lighter alternative for moderate lists (hundreds, not thousands).

Q:How would you support keyboard navigation in a virtualized list?

A: Track a focusedIndex in state. On ArrowDown/ArrowUp, increment/decrement it. If the focused item is outside the viewport, programmatically scroll the container to bring it into view: container.scrollTop = focusedIndex × ITEM_HEIGHT. The virtualization will render it automatically.

Q:What libraries implement this pattern?

A: react-window (lightweight, fixed and variable sizes), react-virtuoso (auto-measures heights, grouped lists), TanStack Virtual (framework-agnostic, headless). For interviews, implement it from scratch to show understanding. In production, use a library — they handle edge cases like SSR, RTL, and accessibility.

Ready to build it yourself?

We've set up 10,000 items and the container. Implement the virtualization logic from scratch.

Built for developers, by developers. Happy coding! 🚀