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.

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
#1Sara Hemdansara.hemdan0@example.comFrontendactive
#2Ziad Refaatziad.refaat1@example.comSREoffline
#3Aya Lotfyaya.lotfy2@example.comDesignaway
#4Adam Adeladam.adel3@example.comPMactive
#5Salma Salehsalma.saleh4@example.comMobileoffline
#6Karim Khaledkarim.khaled5@example.comBackendaway
#7Dina Sherifdina.sherif6@example.comQAactive
#8Omar Zakiomar.zaki7@example.comDataoffline
#9Farah Gamalfarah.gamal8@example.comFrontendaway
#10Amr Nabilamr.nabil9@example.comSREactive
#11Maya Mansourmaya.mansour10@example.comDesignoffline
#12Youssef Anwaryoussef.anwar11@example.comPMaway
#13Nour Hassannour.hassan12@example.comMobileactive
#14Tarek Samitarek.sami13@example.comBackendoffline
#15Lina Shahinlina.shahin14@example.comQAaway
Dataset
100,000
DOM rows mounted
15
Last render
FPS (live)
60

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:

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
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.
← Previous
20. Compressing JavaScript