01 — The 10,000-item problem
10,000 items. 36 pixels each. Do the multiplication in your head — or watch the panel on the right do it for you.
360,000 pixels. That's roughly 900 screen-heights of scrollable space crammed into one <div>. Now look at the gold bar at the top of the red block. That sliver is your viewport — 380 pixels. You can see about 11 items at a time.
Drag the slider to 100,000. The total height climbs to 3.6 million pixels — taller than the Burj Khalifa in pixels. And you can still only see 11 items.
Here's the implementation that almost everyone reaches for first:
.map() doesn't know which items are visible. It builds all 10,000. Every one enters the layout tree, gets styled, gets painted, sits in memory. The browser processes every element even though only 11 are ever on screen.
If those 9,989 items are invisible, why do they exist in the DOM?
02 — Feel the DOM weight
Those numbers are no longer abstract. The panel is now rendering a real scrollable list — 2,000 DOM nodes, each one a genuine element the browser is tracking. (Capped from 10,000 to keep your browser alive.)
Try scrolling. On your machine it probably feels fine — modern browsers handle 2,000 items without visible jank. But “handles it” doesn't mean “for free.” Each DOM node carries real cost:
Memory. A single <div> with a few CSS properties costs roughly 0.5–1KB of render tree overhead — computed styles, layout boxes, paint records. At 2,000 nodes: 1–2MB before your content even loads.
Layout. At 60fps, you have 16.6ms per frame. Layout alone for 2,000 nodes eats 4–8ms. With complex rows — images, buttons, badges — each item becomes 5–10 DOM nodes. A 2,000-item product catalog is really 10,000–20,000 DOM nodes. That's where frames start dropping.
Frame budget at 60fps: 16.6ms ├─ JavaScript: 1ms ├─ Style recalculation: 2ms ├─ Layout (2,000 nodes): 4-8ms ← bottleneck ├─ Paint: 1-2ms ├─ Composite: 0.5ms └─ Remaining: 3-6ms (fragile)
Drag the items slider up. Watch the domNodes counter in the state inspector — it stays pinned at 2,000 no matter how high you go. That's because we capped it to keep your browser alive. In production there is no cap. Ship a product catalog with 10K items and every single one becomes a DOM node your users pay for.
03 — Your viewport is a keyhole
That new minimap tells the whole story. The gold bar is your viewport — a sliver covering 0.1% of the total scroll height. Scroll the list and watch it. It barely budges.
Now look at the rows. The ones inside the viewport have a left border. Everything else is dimmed. Those dimmed rows are the waste — DOM nodes that exist, consume memory, participate in layout, but produce zero visible pixels.
Scroll to the middle and check the state inspector: viewportStart: 500, viewportEnd: 510, wastedNodes: 1989. Nearly two thousand nodes doing nothing. Scroll to the top — wastedNodes: 1989. Scroll to the bottom — wastedNodes: 1989. The waste is constant, regardless of position.
Notice something about these calculations? Every item is exactly 36px tall. If every item is the same height, you don't need to ask the browser which items are visible. You can calculate it:
One Math.floor converts a pixel offset to an array index. No getBoundingClientRect(). No IntersectionObserver. No DOM traversal. O(1) — same cost whether you have 100 items or 100,000.
The browser doesn't know which items are visible until after it processes all of them. It lays out every node, clips to the viewport, and throws away 99.5% of the work. What if we told it which items to skip?
04 — The flip
Click the Windowing toggle that just appeared.
Three things happen at once:
-
The DOM badge flips from red to green. 2,000 drops to roughly 17. Same list, same scrollbar — 99.2% fewer DOM nodes.
-
The minimap's rendered band shrinks to a tiny green sliver. Only items near the viewport exist.
-
The comparison banner shows the ratio. That's ~120× fewer nodes for identical visual output.
Here's the complete implementation — this is the whole trick:
Three parts:
The spacer. One empty <div> with height: 360,000px. This creates the native scrollbar — the browser only cares about container height, not how many children exist.
Positioned items. Only ~17 items exist in the DOM. Each uses position: absolute and transform: translateY() to sit at its correct pixel offset. The browser paints 17 elements. The other 9,983 don't exist.
The scroll handler. On every scroll, recalculate which items are visible and mount only those. Items that leave the viewport are unmounted entirely.
Why transform, not top. Setting top: 18000px triggers layout — the browser recalculates positions. transform: translateY(18000px) is compositor-only — the GPU moves a painted layer without touching layout. The difference: 0.1ms vs 4ms per frame.
05 — The scroll-to-render pipeline
Scroll the list and watch the pipeline visualization in the panel. Four boxes, updating in real time:
scrollTop startIdx mount DOM
Try scrolling slowly. Watch startIdx — it only changes when you cross an item boundary (every 36 pixels). scrollTop climbs continuously, but startIdx jumps in discrete steps. That's the Math.floor doing its work.
Now scroll to any position. The DOM box stays at the same number — 17 nodes whether you're at the top, middle, or bottom of a 10,000-item list. A million-item list computes the same single division.
This is the core insight of fixed-height windowing: because every item is the same height, the entire visibility calculation is arithmetic. No binary search. No DOM measurement. No layout query. One division, one Math.min, one state update.
Look at the row labels — each shows its translateY offset. The browser doesn't care about the 17,964px gap between the spacer top and the first visible item. It just paints 17 elements at their transforms and calls it done.
06 — The overscan buffer
The overscan slider just appeared — and it's at 0. Flick the scroll wheel as fast as you can.
See the blank flash? For a split second, the viewport goes empty before new items appear. The browser scrolled faster than React could mount the next batch:
Now drag overscan to 3 and scroll fast again. The flash is gone. We're rendering 3 extra items above and below the viewport — when the browser outruns React, those buffer items are already painted and waiting.
The code change is one line:
The rows with the colored left border are buffer items. They exist in the DOM but haven't scrolled into view yet. When they do, there's zero delay.
The tradeoff: at overscan: 3, we mount 17 + 6 = 23 items instead of 17. That's 35% more DOM nodes — but still 99.8% fewer than naive rendering. Most virtualisation libraries default to 3–5 items. Too little and fast scrolling flashes. Too much and you undermine the point of windowing.
Here's the complete implementation — what you'd actually ship:
Four concepts, ~50 lines of logic: a spacer for the scrollbar, positioned items for the viewport, a scroll handler for the math, and an overscan buffer for smooth edges. This handles hundreds of thousands of items at 60fps.
The constraint that makes all of this work is fixed item height. Every calculation assumes 36px per item. What happens when items have different heights? That's the next lesson — and the math gets significantly harder.