Build a Virtualized List
Learn how to render 10,000+ items efficiently by only mounting the rows visible in the viewport. A critical performance technique tested in frontend interviews at top companies.
Table of Contents
Problem Statement
Build a virtualized list that renders 10,000 items efficiently. Instead of mounting all 10,000 DOM nodes, only render the rows currently visible in the scrollable viewport.
- Render a scrollable list of 10,000 items
- Only mount DOM nodes for visible rows (+ a small buffer)
- Each row has a fixed height (e.g., 60px)
- Scrollbar should reflect the full list length
- Smooth scrolling with no visible flicker
- Show stats: total items vs. rendered DOM nodes
Why this question?
Virtualization is a core performance technique used in every major app that displays large lists — Slack, Twitter, VS Code, Google Sheets. It tests your understanding of the DOM, scroll mechanics, and the cost of rendering. Interviewers love it because it's practical and has clear measurable impact.
Why Virtualization?
Rendering 10,000 DOM nodes is expensive. Each node costs memory, triggers layout calculations, and slows down paint. Virtualization solves this by only rendering what the user can see.
Without Virtualization
10,000 DOM nodes mounted at once. Slow initial render (1-3 seconds), high memory usage, janky scrolling. The browser has to lay out and paint all nodes.
With Virtualization
~15-20 DOM nodes mounted at any time. Instant render, low memory, smooth 60fps scrolling. Only visible rows exist in the DOM.
| Metric | Naive (10K nodes) | Virtualized (~20 nodes) |
|---|---|---|
| Initial render | 1-3 seconds | < 10ms |
| DOM nodes | 10,000 | ~20 |
| Memory | High | Minimal |
| Scroll FPS | Janky (10-30fps) | Smooth (60fps) |
The golden rule
If the user can only see 10 items at a time, there's no reason to have 10,000 DOM nodes. Virtualization is the technique of keeping the DOM small while making the scrollbar behave as if all items exist.
How It Works
The trick is two nested divs: an outer scrollable container and an inner div sized to the full list height. You position only the visible items inside the inner div using absolute positioning.
┌─ Outer container (overflow: auto, height: 500px) ──┐ │ │ │ ┌─ Inner div (height: 10000 * 60 = 600,000px) ──┐ │ │ │ │ │ │ │ (empty space above — scrolled past) │ │ │ │ │ │ │ │ ┌──────────────────────────────────────────┐ │ │ │ │ │ Row 42 (position: absolute, top: 2520) │ │ │ │ │ │ Row 43 (position: absolute, top: 2580) │ │ │ │ │ │ Row 44 (position: absolute, top: 2640) │ │ │ ← Only these │ │ │ Row 45 (position: absolute, top: 2700) │ │ │ are in the DOM │ │ │ Row 46 (position: absolute, top: 2760) │ │ │ │ │ │ ... │ │ │ │ │ └──────────────────────────────────────────┘ │ │ │ │ │ │ │ │ (empty space below — not scrolled to yet) │ │ │ │ │ │ │ └────────────────────────────────────────────────┘ │ │ │ │ ═══════════ scrollbar ═══════════ │ └─────────────────────────────────────────────────────┘
Outer container creates the viewport
A div with overflow: auto and a fixed height (e.g., 500px). This is what the user sees and scrolls.
Inner div creates the scrollbar
A div with height = totalItems × itemHeight. This makes the scrollbar the correct size even though most of the space is empty.
Visible items are absolutely positioned
Only the items in the viewport (plus a buffer) are rendered. Each is positioned with top: index × itemHeight so it appears in the right place.
The Math Behind It
The core of virtualization is simple arithmetic. Given the scroll position, calculate which items are visible.
const ITEM_HEIGHT = 60; // Fixed height per row const CONTAINER_HEIGHT = 500; // Visible viewport height const TOTAL_ITEMS = 10_000; // Total scrollable height (makes scrollbar correct) const totalHeight = TOTAL_ITEMS * ITEM_HEIGHT; // → 600,000px // First visible item index const startIndex = Math.floor(scrollTop / ITEM_HEIGHT); // scrollTop = 2520 → startIndex = 42 // How many items fit in the viewport const visibleCount = Math.ceil(CONTAINER_HEIGHT / ITEM_HEIGHT); // 500 / 60 → 9 items visible // Last visible item index const endIndex = startIndex + visibleCount; // 42 + 9 = 51 // Slice only the visible items const visibleItems = items.slice(startIndex, endIndex);
| Variable | Formula | Purpose |
|---|---|---|
| totalHeight | items.length × ITEM_HEIGHT | Sets inner div height for correct scrollbar |
| startIndex | Math.floor(scrollTop / ITEM_HEIGHT) | First visible row |
| visibleCount | Math.ceil(CONTAINER_HEIGHT / ITEM_HEIGHT) | How many rows fit in viewport |
| top | index × ITEM_HEIGHT | Absolute position of each rendered row |
Why Math.floor for start, Math.ceil for count?
Math.floor for startIndex because we want the item that's partially scrolled into view (round down). Math.ceil for visibleCount because a partially visible row at the bottom still needs to be rendered (round up).
Scroll Handling
The scroll handler reads scrollTop from the container and triggers a re-render with the new visible window.
const [scrollTop, setScrollTop] = useState(0); const handleScroll = (e: React.UIEvent<HTMLDivElement>) => { setScrollTop(e.currentTarget.scrollTop); }; // On the container: <div onScroll={handleScroll} style={{ height: CONTAINER_HEIGHT, overflow: "auto" }} > {/* ... */} </div>
State vs. ref for scrollTop?
Using useState is the simplest approach — it triggers a re-render on every scroll, which recalculates visible items. For most cases this is fast enough because you're only rendering ~20 items. If you need more performance, you can use useRef + requestAnimationFrame to batch scroll updates. Mention both in interviews.
Buffer & Edge Cases
A buffer renders extra items above and below the viewport to prevent white flashes during fast scrolling.
const BUFFER = 5; // Render 5 extra items above and below const startIndex = Math.floor(scrollTop / ITEM_HEIGHT); const visibleCount = Math.ceil(CONTAINER_HEIGHT / ITEM_HEIGHT); // Clamp to valid array bounds const startIndex = Math.max(0, startIndex - BUFFER); const endIndex = Math.min( ITEMS.length, startIndex + visibleCount + BUFFER ); const visibleItems = ITEMS.slice(startIndex, endIndex); // When rendering, use the original index for positioning: visibleItems.map((item, i) => { const actualIndex = startIndex + i; return ( <div key={item.id} style={{ position: "absolute", top: actualIndex * ITEM_HEIGHT, height: ITEM_HEIGHT, width: "100%", }} > {/* row content */} </div> ); });
| Edge Case | Solution |
|---|---|
| Scroll to top | Math.max(0, startIndex - BUFFER) prevents negative indices |
| Scroll to bottom | Math.min(ITEMS.length, ...) prevents out-of-bounds |
| Fast scrolling flicker | Buffer of 5-10 items above/below ensures rows are ready before they scroll into view |
| Empty list | Check items.length === 0 and show an empty state |
Why not just use overflow: hidden on the buffer?
The buffer items are outside the visible viewport but still in the DOM. They're hidden by the outer container's overflow: auto. When the user scrolls, these pre-rendered items slide into view instantly instead of being created from scratch — that's what prevents the flicker.
Full Implementation
Here's the complete virtualized list. Study how the scroll handler, index calculations, and absolute positioning work together.
"use client"; import { useState } from "react"; interface ListItem { id: number; name: string; email: string; role: string; } const ROLES = [ "Engineer", "Designer", "Product Manager", "Data Scientist", "DevOps", "QA Engineer", ]; const ITEMS: ListItem[] = Array.from({ length: 10_000 }, (_, i) => ({ id: i + 1, name: `User ${i + 1}`, email: `user${i + 1}@example.com`, role: ROLES[i % ROLES.length], })); const ITEM_HEIGHT = 60; const CONTAINER_HEIGHT = 500; const BUFFER = 5; export default function VirtualizedListPage() { const [scrollTop, setScrollTop] = useState(0); // Calculate visible window const startIndex = Math.floor(scrollTop / ITEM_HEIGHT); const visibleCount = Math.ceil(CONTAINER_HEIGHT / ITEM_HEIGHT); const startIndex = Math.max(0, startIndex - BUFFER); const endIndex = Math.min( ITEMS.length, startIndex + visibleCount + BUFFER ); const visibleItems = ITEMS.slice(startIndex, endIndex); const handleScroll = (e: React.UIEvent<HTMLDivElement>) => { setScrollTop(e.currentTarget.scrollTop); }; return ( <div> {/* Stats */} <div> Total: {ITEMS.length.toLocaleString()} | Rendered: {visibleItems.length} </div> {/* Scrollable container */} <div onScroll={handleScroll} style={{ height: CONTAINER_HEIGHT, overflow: "auto", position: "relative", }} > {/* Inner div — full height for scrollbar */} <div style={{ height: ITEMS.length * ITEM_HEIGHT, position: "relative", }} > {/* Only visible items rendered */} {visibleItems.map((item, i) => { const actualIndex = startIndex + i; return ( <div key={item.id} style={{ position: "absolute", top: actualIndex * ITEM_HEIGHT, height: ITEM_HEIGHT, width: "100%", }} > <div>{item.name}</div> <div>{item.email} · {item.role}</div> </div> ); })} </div> </div> </div> ); }
Minimal DOM nodes
Only ~20 DOM nodes exist at any time instead of 10,000. Reduces memory usage and layout/paint cost dramatically.
Correct scrollbar behavior
The inner div's height (totalItems × itemHeight) gives the browser the correct scrollbar size, even though most of the space is empty.
Buffer for smooth scrolling
5 extra items above and below the viewport are pre-rendered to prevent white flashes during fast scrolling.
Variable row heights
For rows with different heights, maintain a cumulative height array. Use binary search to find the startIndex from scrollTop. Libraries like react-virtuoso handle this.
requestAnimationFrame throttling
Wrap the scroll handler in rAF to batch updates to the next paint frame. Prevents unnecessary re-renders during fast scrolling.
Common Interview Follow-up Questions
After building the virtualized list, interviewers dig into performance nuances and real-world scenarios:
Q:How would you handle variable row heights?
A: Maintain an array of cumulative heights. To find startIndex, binary search the array for the scrollTop value. To position a row, use the cumulative height at its index. Measure row heights after render using refs or ResizeObserver. Libraries like react-virtuoso and react-window (VariableSizeList) handle this.
Q:Why use absolute positioning instead of translateY?
A: Both work. Absolute positioning with top is simpler and more intuitive. translateY is GPU-accelerated and avoids layout reflow, which can be faster for very large lists. In practice, the difference is negligible for virtualized lists since you're only rendering ~20 items. Mention both approaches.
Q:How would you add infinite scroll to a virtualized list?
A: Watch the endIndex. When it approaches the end of the loaded data, trigger a fetch for the next page. Append new items to the array. The virtualization logic stays the same — it just operates on a growing array. Use a loading indicator at the bottom.
Q:What about horizontal virtualization (like a spreadsheet)?
A: Apply the same technique on the X axis. Calculate visible columns from scrollLeft. Position cells with left: colIndex × colWidth. For a full grid (rows + columns), you virtualize both axes — this is how Google Sheets works.
Q:How do you handle scroll restoration (e.g., navigating back)?
A: Save the scrollTop value before navigation (in state, context, or sessionStorage). On mount, restore it by setting container.scrollTop = savedValue. The virtualization logic will automatically calculate the correct visible window.
Q:Why not just use CSS content-visibility: auto?
A: content-visibility: auto tells the browser to skip rendering off-screen elements. It's simpler but less predictable — the browser decides what to skip, and it still creates all DOM nodes. Virtualization gives you full control and guarantees minimal DOM nodes. Mention content-visibility as a lighter alternative for moderate lists (hundreds, not thousands).
Q:How would you support keyboard navigation in a virtualized list?
A: Track a focusedIndex in state. On ArrowDown/ArrowUp, increment/decrement it. If the focused item is outside the viewport, programmatically scroll the container to bring it into view: container.scrollTop = focusedIndex × ITEM_HEIGHT. The virtualization will render it automatically.
Q:What libraries implement this pattern?
A: react-window (lightweight, fixed and variable sizes), react-virtuoso (auto-measures heights, grouped lists), TanStack Virtual (framework-agnostic, headless). For interviews, implement it from scratch to show understanding. In production, use a library — they handle edge cases like SSR, RTL, and accessibility.
Ready to build it yourself?
We've set up 10,000 items and the container. Implement the virtualization logic from scratch.
Built for developers, by developers. Happy coding! 🚀