Skip to content

Virtual Lists & Dynamic Height Management

advanced16 min read

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>
  );
}
Mental Model

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>
  );
}
Quiz
A virtual list has 5,000 items at 40px each. The viewport is 400px. With 3 items overscan above and below, how many DOM nodes exist at any scroll position?

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:

  1. Unknown heights: Item heights are only known after rendering (text reflow, images, dynamic content)
  2. 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
}
Common Trap

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.

Quiz
A dynamic virtual list uses a height cache and estimates for unmeasured items. What happens when a measured height differs from the estimate?

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.

ApproachgetOffsetupdateHeightMemory
Sum from 0O(n)O(1)O(n)
Prefix sumO(1)O(n) rebuildO(n)
Fenwick treeO(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
Quiz
Increasing overscan from 3 to 20 items. What is the trade-off?

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>
  );
}
Key Rules
  1. 1Rendering 10K+ DOM nodes causes multi-second initial render, excessive memory usage, and layout thrashing. Virtualize any list above ~100 complex items.
  2. 2Virtual lists render only visible items + overscan buffer. DOM node count is O(viewport), not O(total items).
  3. 3Fixed-height lists have O(1) offset calculation: offset = index × height. Dynamic heights require summing, which is O(n) naively.
  4. 4Measure dynamic item heights with ResizeObserver after render. Cache measurements to avoid redundant work.
  5. 5Height estimate mismatches cause scroll jumps. Compensate by adjusting scrollTop when measured heights differ from estimates.
  6. 6Use prefix sums for O(1) offset lookup or Fenwick trees for O(log n) lookup with O(log n) updates.
  7. 7Overscan of 3-5 items prevents empty flashes during normal scrolling. Increase only if fast-scroll artifacts are visible.
Interview Question

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.