Lesson 15 of 30
Rendering 5,000 DOM nodes to show 15 visible rows costs real time and memory at every pipeline stage — even though 4,985 of them are invisible the entire time.
12 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
Question 1 of 3
In a hand-rolled virtualized list, what is the full-height spacer element's actual job?
Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
Rendering a long list the naive way — one real DOM node per item, all at once — costs more than it looks like it should, and the cost isn't concentrated in one place. More nodes means more work for Layout to position, more work for Paint to fill in pixels for, and more memory held for elements that are, at any given moment, mostly not even visible. A viewport can only physically show a small window of rows; everything else is rendered and paid for without ever being seen.
List virtualization (also called "windowing") fixes this by rendering only the items currently within the visible scroll viewport, plus a small buffer of extra rows just outside it. As the user scrolls, the same small set of real DOM nodes gets reused — their content swapped to represent whichever data items have scrolled into range — instead of creating and destroying thousands of nodes. The DOM node count stays roughly constant no matter how large the underlying list actually is; only which data each node currently displays changes.
// Naive: creates one real DOM node per item, all 10,000 at once
function NaiveList({ items }) {
return (
<div style={{ overflowY: "auto", height: 500 }}>
{items.map((item) => (
<Row key={item.id} item={item} />
))}
</div>
);
}
// Virtualized: only rows in (or near) the visible window are real DOM nodes
function VirtualList({ items, rowHeight = 40, viewportHeight = 500 }) {
const [scrollTop, setScrollTop] = useState(0);
const startIndex = Math.floor(scrollTop / rowHeight);
const visibleCount = Math.ceil(viewportHeight / rowHeight) + 4; // small buffer
const visibleItems = items.slice(startIndex, startIndex + visibleCount);
return (
<div style={{ overflowY: "auto", height: viewportHeight }} onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}>
{/* A spacer the full height of ALL items — this is what makes the scrollbar behave correctly */}
<div style={{ height: items.length * rowHeight, position: "relative" }}>
{visibleItems.map((item, i) => (
<div key={item.id} style={{ position: "absolute", top: (startIndex + i) * rowHeight, height: rowHeight }}>
<Row item={item} />
</div>
))}
</div>
</div>
);
}
A long list of 40 items, scrolled near the top — only a handful are within the visible viewport.
The full-height spacer div is what makes this work visually: it gives the scroll container the correct total scrollable height (so the scrollbar's size and position look exactly like all 10,000 rows exist), while the actual rendered rows are absolutely positioned inside it based on their real index, and there are only ever a couple dozen of them in the DOM at once. Scroll the container, scrollTop changes, startIndex recalculates, and a different slice of items gets rendered into the same small set of DOM nodes.
startIndex a simple division. Variable height requires measuring rendered items and maintaining a position cache — most virtualization libraries support it, but it's a real complexity jump, not a minor detail."How would you render a list of 100,000 rows without freezing the page?" is a common systems-flavored frontend question, and explaining the windowing mechanism — a fixed small set of real DOM nodes with swapped content, backed by a full-height spacer for correct scroll behavior — demonstrates real understanding, versus just naming a library.