01 — The Naive Rendering Problem

Render 500 items into the DOM. Every single one — 500 <div> elements in the document. The browser calculates styles for all 500, computes layout for all 500, and paints all 500. Scroll down and the browser repeats the entire rendering pipeline for every frame.

The right panel shows what this looks like: 500 DOM nodes, all mounted, the browser working to render elements the user will never see.

The fix isn't about faster rendering. It's about rendering less.

02 — Phase 1: Windowed Rendering

The first optimization is the most dramatic. Instead of mounting all 500 items, calculate which ones are visible in the viewport and mount only those — typically 8-12 items.

The trick: an empty container with height: totalItems × itemHeight fakes the scrollbar height, while only the visible items exist in the DOM. The scroll event handler computes floor(scrollTop / itemHeight) to find the first visible item.

The right panel shows the DOM count dropping from 500 to approximately 12. Same visual result — 500-item scrollbar, same content — but the browser processes 97% fewer nodes.

03 — Phase 2: Transform Positioning

Phase 1 positions items with top: Npx — which triggers a layout recalculation on every scroll. Phase 2 switches to transform: translateY(Npx) — which runs entirely on the compositor thread.

The visual result is identical, but the performance difference is significant. On a 60fps display, each frame has 16ms to complete. Layout recalculation can consume 5-10ms of that budget. Transform-only updates take less than 1ms.

04 — Phase 3: Overscan Buffer

Phase 2 renders exactly the visible items — nothing more. During fast scrolling, the browser renders a frame before React can mount the new items that should appear at the scroll edge. This produces blank flashes — a visible white gap before the content catches up.

Phase 3 adds an overscan buffer: a few extra items above and below the viewport. These items are already in the DOM before they become visible, eliminating the gap.

The right panel shows the viewport with 3 extra items above and 3 below. DOM count rises from 12 to ~18, but the visual result is smooth scrolling without any flicker.

05 — The Complete Pipeline

All four phases compose into a single pipeline:

  1. Spacer — fake scroll height for the scrollbar
  2. Window — mount only visible items (by scroll position)
  3. Transforms — position with translateY not top
  4. Overscan — buffer items above and below

Each phase solves a specific problem introduced by the previous one. The right panel shows all four as a combined pipeline — each phase a card showing what it optimizes.

06 — Core Implementation

The core of a virtual scroller fits in under 30 lines of logic. The scroll handler computes the visible range, the render function mounts only those items with transform positioning, and overscan extends the range by a fixed count.

TS
1function VirtualList({ items, itemHeight, viewportHeight }: Props) {
2 const [scrollTop, setScrollTop] = useState(0);
3 const overscan = 3;
4 const totalHeight = items.length * itemHeight;
5 const startIdx = Math.max(0,
6 Math.floor(scrollTop / itemHeight) - overscan);
7 const endIdx = Math.min(items.length,
8 Math.ceil((scrollTop + viewportHeight) / itemHeight)
9 + overscan);
10
11 return (
12 <div onScroll={e => setScrollTop(e.currentTarget.scrollTop)}
13 style={{ height: viewportHeight, overflow: "auto" }}>
14 <div style={{ height: totalHeight, position: "relative" }}>
15 {items.slice(startIdx, endIdx).map((item, i) => (
16 <div key={startIdx + i}
17 style={{ position: "absolute",
18 transform: `translateY(${(startIdx + i) * itemHeight}px)`,
19 height: itemHeight }}>
20 {item}
21 </div>
22 ))}
23 </div>
24 </div>
25 );
26}

The right panel shows this code alongside the key measurements: total height, visible range, overscan range, and DOM count.

07 — Production Libraries

These four phases are the foundation that every virtual scroll library builds on. react-window and @tanstack/virtual both implement this exact pipeline internally.

react-window is the simpler choice — fixed API, smaller bundle (6KB), battle-tested for fixed-height lists. @tanstack/virtual is framework-agnostic, supports variable heights with measurement, and offers more control over the rendering pipeline.

Both handle the details this lesson covered: spacer containers for scroll height, windowed rendering for DOM efficiency, transform positioning for compositor performance, and overscan for smooth scrolling. The next stops explore what happens when you need variable-height items (Fixed vs Variable Height) and when the DOM itself becomes the bottleneck (Canvas vs DOM).

Naive Rendering

Rendering all 500 items creates 500 DOM nodes. The browser must lay out, paint, and composite every one — even those thousands of pixels off-screen.

DOM Nodes500

Each cell represents one DOM node. At 500 nodes, initial render takes 200-400ms and scrolling janks on mid-range devices.