Virtualized lists: rendering only what's on screen
A list of 10,000 rows rendered naively means 10,000 real DOM nodes sitting in memory and in the layout tree, when the viewport can only ever show 15-20 of them at once. This lesson works through the actual mechanism behind virtualized lists — a small mounted window, spacers that keep the scrollbar honest, and the index math that decides what's currently real.
Virtualized lists: rendering only what's on screen
Open a chat app with years of message history, or a spreadsheet with tens of thousands of rows, and scroll through it. The scrollbar behaves exactly like a list that long should — a tiny thumb, a long track, smooth motion — and yet the app never seems to slow down no matter how far you scroll. If every row in that list were a real DOM node the whole time, the tab would be struggling long before you got there. It isn't struggling, because almost none of those rows are actually real DOM nodes at all. Only a handful near your current scroll position are; the rest are just data, waiting.
That's the whole idea this lesson is about: render only what the user can currently see, plus a small margin, and represent everything else as nothing more than numbers you haven't turned into nodes yet. The mechanism has a name — virtualization, or windowing — and once you've seen how it's built by hand, every library that does it for you (react-window, FlatList, and the rest) stops looking like magic.
Here's the path: why a fully-rendered long list is wasteful even when it "works," how a small recycled window of mounted rows replaces thousands of never-shown nodes, why spacers are the detail that keeps the scrollbar honest, and the actual index arithmetic that ties a scroll offset to "which rows should exist right now."
The cost of a node nobody is looking at
It's tempting to think of an off-screen DOM node as free — the user can't see it, so what's the harm? But a DOM node isn't a picture; it's a real object that the browser and React both have to keep track of. Every row you render, on-screen or not, is at minimum: a JavaScript object in React's element tree, a corresponding node in the actual DOM tree, an entry the browser's layout engine has to consider when it computes where everything on the page goes, and however many child nodes that row itself contains — text nodes, images, nested <div>s — each carrying the same costs again. None of that goes away just because the row happens to be scrolled 40,000 pixels out of view. The browser still has to know it exists, still has to account for it during layout, still holds it in memory.
Put a real number on it. A viewport is, physically, a few hundred pixels tall — enough for maybe 15 to 20 rows of typical list-item height, depending on the row and the screen. If the underlying data has 10,000 rows and you render all of them naively — mapping the full array to JSX with no windowing at all — you've created 10,000 real DOM nodes (times however many nodes each row's markup contains internally) to serve a viewport that can show 20 of them. Ninety-nine point eight percent of that rendered content is invisible at any given instant, and if the user never scrolls to the bottom, some of it may be invisible for the entire session. You paid the memory and layout cost anyway.
The fix: a small, recycled window of rows
The naive instinct once you notice this is "only render the rows that are visible" — which is correct, but the mechanical detail that makes it actually work is more specific than that. As the user scrolls, you don't want to be creating a brand-new DOM node for every row that newly enters the viewport and destroying one for every row that leaves it, one at a time, forever. Mounting and unmounting real DOM nodes is itself real work, and doing it continuously during a scroll gesture would trade one performance problem for another.
Instead, a virtualized list keeps a small, roughly constant-sized pool of row components mounted at all times — just enough to cover the visible viewport plus a little buffer on each side — and as the user scrolls, it doesn't grow or shrink that pool. It reassigns which item's data each already-mounted row is currently displaying. Row component #7 in the pool might show item 214 one moment and item 215 a moment later, having never unmounted in between; only the data flowing into it changed. This is the literal meaning of "virtualization" in the name: from the DOM's point of view, a fixed, small number of rows exist — five, ten, twenty, whatever the pool size is — no matter whether the list they represent has a hundred items or a hundred thousand. Everything beyond the mounted pool is virtual: real as data, nonexistent as nodes.
The buffer — a few extra rows mounted just outside the visible edges of the viewport — exists for one reason: latency. If you mounted exactly the visible rows and not one more, then the instant the user scrolled a single pixel, a brand-new row would need to appear at the trailing edge before the browser paints the next frame, and any delay in that shows up as a blank flash where content should be. A small buffer means that row is already mounted, just outside view, by the time it needs to slide into view.
Spacers: keeping the scrollbar honest about a list that mostly isn't there
There's a problem this creates that isn't obvious until you hit it. If you only mount the 15 or 20 visible rows and render nothing else, the scrollable container's content is only as tall as those 15 or 20 rows — because as far as the browser's layout engine is concerned, that's all the content there is. The scrollbar shrinks to match, its thumb size and position no longer correspond to where you actually are in the full 10,000-item list, and the moment new rows swap in, the container's height changes again, causing the scroll position itself to jump.
The fix is to keep the scrollable area's total height mathematically correct at all times, independent of how many rows are actually mounted. You do this with spacer elements — empty space, not empty rows — placed above and below the rendered window. The spacer above is exactly as tall as all the items that come before the current window would be, if they were rendered (item count before the window × item height, for fixed heights; a running sum, for variable ones). The spacer below is the mirror image. The rendered window sits between them. The result is that the container's total height always equals what it would be if all 10,000 items were really there, so the scrollbar's size and thumb position stay correct throughout — even though, at any instant, the DOM backing that scrollbar contains only a few dozen real rows and two blocks of empty space standing in for the other 9,980 items.
The index math, made concrete
With fixed-height rows, the arithmetic connecting "where the user has scrolled to" and "which rows should be mounted" is genuinely simple. Given the current scroll offset, the height of one row, and the height of the viewport, you can compute the first and last visible index directly, pad each side with a buffer, and clamp to the array's bounds:
function getVisibleRange({ scrollTop, itemHeight, viewportHeight, itemCount, buffer = 3 }) {
const firstVisible = Math.floor(scrollTop / itemHeight);
const lastVisible = Math.floor((scrollTop + viewportHeight) / itemHeight);
const startIndex = Math.max(0, firstVisible - buffer);
const endIndex = Math.min(itemCount - 1, lastVisible + buffer);
return { startIndex, endIndex };
}Rendering the list, then, is just: run that function on every scroll event, slice the underlying data to [startIndex, endIndex], and render only that slice inside a container carrying the spacer math from the section above.
function VirtualList({ items, itemHeight, viewportHeight }) {
const [scrollTop, setScrollTop] = useState(0);
const { startIndex, endIndex } = getVisibleRange({
scrollTop,
itemHeight,
viewportHeight,
itemCount: items.length,
});
const visibleItems = items.slice(startIndex, endIndex + 1);
const topSpacerHeight = startIndex * itemHeight;
const bottomSpacerHeight = (items.length - endIndex - 1) * itemHeight;
return (
<div
style={{ height: viewportHeight, overflowY: "auto" }}
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
>
<div style={{ height: topSpacerHeight }} />
{visibleItems.map((item, i) => (
<Row key={startIndex + i} height={itemHeight} data={item} />
))}
<div style={{ height: bottomSpacerHeight }} />
</div>
);
}Every row that ever mounts here is one of a small, bounded set determined by startIndex/endIndex — never all 10,000 at once — and the two spacer divs are what keep overflowY: auto's scrollbar reporting the correct size and position for the full, mostly-virtual list.
That's the entire mechanism. It's deliberately the simplified, fixed-height case — no dynamic measurement, no horizontal variant, no handling for items that resize after they mount. Real lists routinely need all three, which is exactly what a library like react-window exists to handle correctly: it does this same windowing and spacer math, but with the harder edge cases — variable item heights measured on the fly, horizontal lists, dynamically changing item counts — worked out and tested. Once the version above makes sense as a mechanism, reaching for react-window in an actual app is the right call, not a cop-out; you're not skipping understanding it, you're skipping re-solving problems that are already solved.
If the idea of not making React or the DOM do more work than the user can actually perceive sounds familiar, it's the same instinct behind Refs vs. state: why the hot path skips setState — there it was about how often you render; here it's about how much you render at all.
Go deeper
- web.dev — Virtualize large lists with react-window — A concrete, practical walkthrough of the exact recycling/spacer mechanism this lesson describes, using the standard library for it on the web.
- react-window — GitHub — The library itself, for the harder edge cases (variable heights, horizontal scroll, dynamic measurement) a hand-rolled version glosses over.
- React Native docs — FlatList — The mobile-native equivalent doing the same virtualization job at the native-view level, referenced in this lesson's WhereInFable note.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- A 10,000-row list is rendered with no virtualization, and the user never scrolls past row 50. Explain concretely what cost the other 9,950 rows are still imposing, even though they're never seen.
- What specifically does 'recycling' mean in a virtualized list — what happens to a mounted row component when the item it should display changes, and what doesn't happen?
- Why does a virtualized list keep a small buffer of rows mounted just outside the visible viewport, rather than mounting exactly the visible rows and nothing more?
- If you removed the top and bottom spacer elements from a virtualized list but kept everything else the same, what would break, and why?
- Given scrollTop, itemHeight, and viewportHeight, walk through how startIndex and endIndex are derived, and explain what the buffer value is added for.
- Why does variable row height make the index math meaningfully harder than the fixed-height case this lesson uses?
- React Native's FlatList is described in this lesson as doing the 'same mechanism' as a hand-rolled web virtualized list. What, specifically, is the same, and what's different about where the recycling happens?
This closes out Frontend, under the hood.