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:
Memory — every row is DOM nodes holding memory; 50,000 rows is a lot of nodes.
Slow initial render — the browser must lay out and paint every row before the page settles, freezing the UI on weaker devices.
Laggy scrolling — a huge DOM means more to recalculate as you scroll.
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.
How far does it scale? All the way. Crank the stress test below to 1,000,000 rows — the FPS meter, the render timer, and the DOM-node count are all real measurements from your browser, right now. Then flip to naïve and watch “last render” explode:
InteractiveStress test — one million rows, live FPS
All four numbers are real, measured in your browser right now. Flip to naïve and watch “last render” explode; scroll hard at 1,000,000 rows and the FPS meter barely notices.
Virtualized: ~21 mounted rows and a single-digit render at any size. Naïve is capped at 5,000 rows to protect your tab — and still takes hundreds of milliseconds.
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:
Render rows 9–15 (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
conststartIndex=Math.floor(scrollTop/itemHeight)-overscan;constvisibleCount=Math.ceil(viewportHeight/itemHeight);constendIndex=startIndex+visibleCount+overscan*2;constrows=items.slice(startIndex,endIndex);// only these mount// outer wrapper is the FULL height so the scrollbar is correct<divstyle={{height:items.length*itemHeight}}>{/*offsettherenderedwindowintoplace*/}<divstyle={{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:
Wins
Costs
Memory & nodes
Tiny, constant DOM
—
Initial render
Fast regardless of size
—
Complexity
—
More moving parts, harder to debug
Accessibility
—
Screen readers & Ctrl+F miss off-DOM rows
SEO
—
Content not in the DOM isn't crawlable
Q1Multiple choice
You're tempted to virtualize a public marketing page's list of 40 product cards that must be SEO-crawlable and Ctrl+F-able. Good idea?
Q2Multiple choice
Your virtual list flashes blank rows during fast scrolling. Adding over-scan fixes it, but scrolling very fast still costs a bit more. What's the over-scan trade-off?
Q3Multiple choice
Virtualizing a 100k-row table makes scrolling smooth and initial render fast. Does it also reduce how much you DOWNLOAD and hold in memory?
Q4Multiple choice
Your virtual list assumes a fixed itemHeight, but rows now have variable heights (some wrap to 3 lines). The scrollbar and row positions go wrong. Why, and the fix?
Q5Sort each scenario
For each property, does virtual scrolling help, hurt, or leave it unchanged?
Number of DOM nodes mounted
Initial render time for a huge list
SEO crawlability of all rows
Find-in-page (Ctrl+F) across every row
How much data you fetch from the server
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.