Frontend Performance/Rendering at Scale
Lesson 20 of 20 · Episode 19

Virtual Scrolling

Render a list of 100,000 rows without melting the browser by only mounting the handful that are on screen.

VirtualizationWindowingDOM nodesScrolling
Watch on YouTube ↗

The capstone. Everything so far has been about shipping fewer bytes; this is about rendering fewer DOM nodes. When a list has thousands of rows, the fix isn't a faster computer — it's realizing the user can only see a dozen at a time. Virtual scrolling renders only those, and makes a 100,000-row list scroll like it has ten.

The problem

Why big lists hurt

Render 5,000 rows (a dropdown, a table, a feed) without pagination and you pay three times:

The flagship

100,000 rows, live

Below is a real, working virtualized list. Crank the dataset to 100,000 rows and scroll — buttery, because only ~15 rows are ever in the DOM. Flip to Naïve and watch the DOM-node count explode (and the scroll get heavy):

InteractiveVirtualized vs. naïve — watch the DOM-node count
Rows in dataset
100,000
DOM nodes mounted
17
Scroll health
Smooth
#0Row item 1
#1Row item 2
#2Row item 3
#3Row item 4
#4Row item 5
#5Row item 6
#6Row item 7
#7Row item 8
#8Row item 9
#9Row item 10
#10Row item 11
#11Row item 12
#12Row item 13
#13Row item 14
#14Row item 15
#15Row item 16
#16Row item 17
Virtualized: no matter the dataset, only ~17 rows are in the DOM — the window slides as you scroll. 100,000 rows scroll as smoothly as 10.
Virtualized keeps ~15 nodes mounted no matter the dataset size. Naïve mounts every row — which is why 100k would freeze the tab.
Key idea
The insight: the user can never see 100,000 rows at once — only the handful in the viewport. So only render those, and swap them as the scroll position changes. The list is “virtual” — the other 99,985 rows don't exist in the DOM.
The mechanism

How windowing works

It's just arithmetic on the scroll position. From scrollTop and a known row height, you compute which slice (“window”) is visible, render only that, and give the scroll container a tall spacer so the scrollbar still reflects the full list. Drag the slider:

InteractiveThe windowing math
startIndex = scrollTop / itemHeight
360 / 40 = 9
visibleCount = viewportH / itemHeight
240 / 40 = 6
wrapperHeight = total × itemHeight
1000 × 40 = 40,000px
Render rows 915 (plus an over-scan buffer), offset by translateY(360px). The tall wrapper keeps the scrollbar honest.
viewport
row 9
row 10
row 11
row 12
row 13
row 14
row 15
only these are in the DOM
startIndex = scrollTop / itemHeight; visibleCount = viewportHeight / itemHeight. The wrapper's full height keeps the scrollbar honest.
the core of a virtual list
const startIndex = Math.floor(scrollTop / itemHeight) - overscan;
const visibleCount = Math.ceil(viewportHeight / itemHeight);
const endIndex = startIndex + visibleCount + overscan * 2;

const rows = items.slice(startIndex, endIndex);   // only these mount

// outer wrapper is the FULL height so the scrollbar is correct
<div style={{ height: items.length * itemHeight }}>
  {/* offset the rendered window into place */}
  <div style={{ transform: `translateY(${startIndex * itemHeight}px)` }}>
    {rows.map(/*  */)}
  </div>
</div>
Two details that matter

Positioning & over-scan

transform, not top

You can position the window with absolute top values, but that triggers layout/reflow on every scroll. Moving the whole window with transform: translateY() is far better — it runs on the compositor / GPU and skips layout and paint (exactly the cheap-animation rule from the Critical Rendering Path lesson).

over-scan to kill the flicker

Scroll fast and you can out-run the render, flashing a blank gray gap. Over-scan (a.k.a. buffering) fixes it: render a few extra rows above and below the viewport so there's always something there as new rows stream in.

You usually reach for a library
Windowing has fiddly edge cases (variable row heights, jumpy scrollbars). In React, react-window and react-virtualized (or TanStack Virtual) handle them. Understanding the mechanism is what lets you use them well — and debug them when they misbehave.
It's not free

The trade-offs

Virtualization is powerful but adds real costs — reach for it when a list is genuinely large, not by default:

WinsCosts
Memory & nodesTiny, constant DOM
Initial renderFast regardless of size
ComplexityMore moving parts, harder to debug
AccessibilityScreen readers & Ctrl+F miss off-DOM rows
SEOContent not in the DOM isn't crawlable
Q1Multiple choice
What does virtual scrolling actually render?
Q2Multiple choice
Why give the wrapper a height of total × itemHeight?
Q3Multiple choice
Why position the window with transform: translateY() instead of top?
Q4Multiple choice
Which is a genuine downside of virtual scrolling?
Key takeaways
  • Big lists cost memory, slow initial render, and laggy scroll — one DOM node per row.
  • Virtual scrolling (windowing) renders only the visible rows + a small buffer.
  • Compute the window from scrollTop / itemHeight; a full-height spacer keeps the scrollbar correct.
  • Use transform: translateY() (GPU) and over-scan to avoid blank flashes.
  • Trade-offs: complexity, accessibility, and SEO — use it for genuinely large lists.
← Previous
20. Compressing JavaScript