Virtual Scrolling
Render a list of 100,000 rows without melting the browser by only mounting the handful that are on screen.
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.
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.
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):
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:
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>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.
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 |
total × itemHeight?- →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.