Virtual Lists & Dynamic Height Management
The Problem: 10,000 DOM Nodes
Try rendering a list of 10,000 items in the DOM. Go ahead. Watch the browser choke. Even if each item is a simple <div> with text, the cost is brutal:
- Initial render: 10K DOM nodes take 500-2000ms to construct and lay out
- Memory: Each DOM node consumes ~1KB of heap memory. 10K nodes = ~10MB baseline
- Layout: Any style change forces the browser to recalculate layout for thousands of elements
- Scroll performance: The browser must composite thousands of layers and manage paint for a massive scroll area
// ❌ Renders all 10,000 items — freezes the browser
function NaiveList({ items }) {
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.content}</li>
))}
</ul>
);
}
A virtual list is a viewport into a much larger dataset — like looking through a window at a landscape. You only see what fits in the window frame. The landscape exists (your data), but you only paint the portion visible through the window. As you scroll, the window moves and you paint the new visible portion, discarding what is no longer visible. The total DOM nodes never exceed "viewport height / item height + buffer."
The Windowing Concept
The fix is surprisingly elegant: instead of rendering all items, just render the ones the user can actually see, plus a small buffer:
Total items: 10,000 (each 50px tall)
Viewport: 500px tall
Visible items: 500px / 50px = 10 items
Buffer (overscan): 5 items above + 5 items below
DOM nodes at any time: 20 (not 10,000)
The container has a total height matching all items (10,000 × 50px = 500,000px), creating the correct scrollbar. But only ~20 DOM nodes exist, absolutely positioned at the correct scroll offsets.
Fixed-Height Virtual List: Implementation
interface VirtualListProps {
items: unknown[];
itemHeight: number;
containerHeight: number;
overscan?: number;
renderItem: (item: unknown, index: number) => React.ReactNode;
}
function VirtualList({
items,
itemHeight,
containerHeight,
overscan = 5,
renderItem,
}: VirtualListProps) {
const [scrollTop, setScrollTop] = useState(0);
const totalHeight = items.length * itemHeight;
// Calculate visible range
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
const endIndex = Math.min(
items.length,
Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan
);
const visibleItems = [];
for (let i = startIndex; i < endIndex; i++) {
visibleItems.push(
<div
key={i}
style={{
position: 'absolute',
top: i * itemHeight,
height: itemHeight,
width: '100%',
}}
>
{renderItem(items[i], i)}
</div>
);
}
return (
<div
style={{ height: containerHeight, overflow: 'auto', position: 'relative' }}
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
>
{/* Spacer for total scroll height */}
<div style={{ height: totalHeight, position: 'relative' }}>
{visibleItems}
</div>
</div>
);
}
The Dynamic Height Challenge
Fixed-height lists are the easy case. But what happens when your items have different heights? That is where things get interesting, because the offset calculation breaks down:
offset(index) = index × itemHeight // Simple multiplication
Dynamic-height items break this. If items have variable heights, calculating the scroll offset for item 5,000 requires summing the heights of items 0-4,999:
offset(index) = sum(heights[0..index-1]) // O(n) summation
This creates two problems:
- Unknown heights: Item heights are only known after rendering (text reflow, images, dynamic content)
- Expensive offset calculation: Scrolling to item 5,000 requires summing 5,000 heights
Measuring Heights with ResizeObserver
function DynamicVirtualList({ items, containerHeight, estimatedHeight = 50 }) {
const [scrollTop, setScrollTop] = useState(0);
const heightCache = useRef<Map<number, number>>(new Map());
// Get height for an item (measured or estimated)
function getHeight(index: number): number {
return heightCache.current.get(index) ?? estimatedHeight;
}
// Calculate offset by summing heights
function getOffset(index: number): number {
let offset = 0;
for (let i = 0; i < index; i++) {
offset += getHeight(i);
}
return offset;
}
// Find start index from scroll position (binary search)
function findStartIndex(scrollTop: number): number {
let low = 0;
let high = items.length - 1;
while (low <= high) {
const mid = (low + high) >>> 1;
const offset = getOffset(mid);
if (offset < scrollTop) {
low = mid + 1;
} else {
high = mid - 1;
}
}
return Math.max(0, low - 1);
}
const totalHeight = getOffset(items.length);
const startIndex = findStartIndex(scrollTop);
// Render items and measure them
const visibleItems = [];
let offset = getOffset(startIndex);
let index = startIndex;
while (offset < scrollTop + containerHeight && index < items.length) {
const i = index;
visibleItems.push(
<MeasuredItem
key={i}
index={i}
offset={offset}
onMeasure={(height) => {
if (heightCache.current.get(i) !== height) {
heightCache.current.set(i, height);
// Trigger re-render to update positions
}
}}
>
{renderItem(items[i], i)}
</MeasuredItem>
);
offset += getHeight(index);
index++;
}
// ...render container with scroll handler
}
The getOffset function above is O(n) — summing heights from 0 to the target index. For 10,000 items, this runs 10,000 iterations on every scroll event. Combined with findStartIndex doing binary search (log n calls to getOffset, each O(n)), the total complexity is O(n log n) per scroll event. This is too slow for smooth scrolling. The next chapter covers Fenwick trees for O(log n) offset calculation.
Height Caching Strategies
So how do you actually store and look up these heights efficiently? You have a few options, and the trade-offs matter.
Simple Map Cache
const heightCache = new Map<number, number>();
function getHeight(index: number): number {
return heightCache.get(index) ?? estimatedItemHeight;
}
Fast lookup, but getOffset(index) requires iterating from 0 to index.
Prefix Sum Array
// offsets[i] = sum of heights from 0 to i-1
const offsets = new Float64Array(items.length + 1);
function buildPrefixSums() {
offsets[0] = 0;
for (let i = 0; i < items.length; i++) {
offsets[i + 1] = offsets[i] + getHeight(i);
}
}
function getOffset(index: number): number {
return offsets[index]; // O(1) lookup
}
O(1) offset lookup, but updating a single height requires rebuilding the entire prefix sum array — O(n) per update.
Fenwick Tree (Best)
O(log n) for both offset queries and height updates. Covered in the next chapter.
| Approach | getOffset | updateHeight | Memory |
|---|---|---|---|
| Sum from 0 | O(n) | O(1) | O(n) |
| Prefix sum | O(1) | O(n) rebuild | O(n) |
| Fenwick tree | O(log n) | O(log n) | O(n) |
Scroll Position Restoration
Here is a detail that separates polished apps from frustrating ones. When a user navigates away and returns, the scroll position should be exactly where they left it. Virtual lists make this harder because the DOM is rebuilt from scratch.
function useScrollRestoration(key: string) {
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Restore on mount
const saved = sessionStorage.getItem(`scroll-${key}`);
if (saved && scrollRef.current) {
scrollRef.current.scrollTop = parseFloat(saved);
}
// Save on unmount
return () => {
if (scrollRef.current) {
sessionStorage.setItem(
`scroll-${key}`,
String(scrollRef.current.scrollTop)
);
}
};
}, [key]);
return scrollRef;
}
Why scroll restoration is tricky with dynamic heights
If heights are estimated on first render and measured later, the total scroll height changes as items are measured. Restoring scrollTop = 15000 when the total height has changed from an estimated 500K to a measured 480K can place the user at the wrong item. A more robust approach stores the item index and pixel offset within that item, then uses the height cache to calculate the correct scrollTop on restore. Libraries like react-window and @tanstack/virtual handle this automatically.
Overscan: Preventing Empty Flashes
Overscan renders extra items above and below the visible area. Without overscan, fast scrolling shows empty space before new items render:
Without overscan (overscan = 0):
[empty] ← just scrolled past, not yet rendered
[Item 5] ← first visible
[Item 6]
[Item 7]
[Item 8] ← last visible
[empty] ← not yet rendered
With overscan (overscan = 3):
[Item 2] ← overscan (off-screen above)
[Item 3] ← overscan
[Item 4] ← overscan
[Item 5] ← first visible
[Item 6]
[Item 7]
[Item 8] ← last visible
[Item 9] ← overscan (off-screen below)
[Item 10] ← overscan
[Item 11] ← overscan
Libraries
Unless you have very specific requirements, don't build this from scratch. For production, use an established library:
- @tanstack/react-virtual: Headless (no DOM opinions), supports dynamic heights, React 18/19 compatible
- react-window: Lightweight (6KB), fixed and variable height lists, by the creator of react-virtualized
- react-virtuoso: Dynamic heights with automatic measurement, grouping, reverse scrolling (chat), table support
// @tanstack/react-virtual example
import { useVirtualizer } from '@tanstack/react-virtual';
function List({ items }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // Estimated item height
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map(virtualItem => (
<div
key={virtualItem.key}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
transform: `translateY(${virtualItem.start}px)`,
width: '100%',
}}
>
{items[virtualItem.index].content}
</div>
))}
</div>
</div>
);
}
- 1Rendering 10K+ DOM nodes causes multi-second initial render, excessive memory usage, and layout thrashing. Virtualize any list above ~100 complex items.
- 2Virtual lists render only visible items + overscan buffer. DOM node count is O(viewport), not O(total items).
- 3Fixed-height lists have O(1) offset calculation: offset = index × height. Dynamic heights require summing, which is O(n) naively.
- 4Measure dynamic item heights with ResizeObserver after render. Cache measurements to avoid redundant work.
- 5Height estimate mismatches cause scroll jumps. Compensate by adjusting scrollTop when measured heights differ from estimates.
- 6Use prefix sums for O(1) offset lookup or Fenwick trees for O(log n) lookup with O(log n) updates.
- 7Overscan of 3-5 items prevents empty flashes during normal scrolling. Increase only if fast-scroll artifacts are visible.
Q: Design a virtual list component that handles dynamic-height items. What are the key challenges and how do you solve them?
A strong answer covers: only render visible items plus overscan, use a height cache with estimated defaults, measure actual heights via ResizeObserver after render, calculate offsets (prefix sum or Fenwick tree), handle scroll jumps when estimates are corrected (scroll anchoring), restore scroll position on re-mount. Mention binary search for finding the start index from scrollTop. Discuss trade-offs of overscan size. Bonus: handling items that resize after initial measurement (e.g., image loads, expand/collapse), and the O(n) to O(log n) improvement from Fenwick trees for offset calculations.