How would you design a virtualized list for 100K+ items in React?

Advanced25 min interview
Skills tested:
List Virtualization AlgorithmsVariable Height MeasurementScroll Performance OptimizationData Structure DesignInfinite Loading ArchitectureBrowser Rendering Internals

Advertisement

🧩 Scenario

Your application must display datasets containing hundreds of thousands of records. - Render only visible rows to minimize DOM usage - Support both fixed-height and variable-height items - Maintain smooth 60 FPS scrolling - Support jump-to-index navigation - Integrate with infinite loading and server pagination - Preserve scroll position when data changes The solution should scale efficiently while remaining responsive on low-end devices.

🧠 Architecture Walkthrough

The windowing contract: a phantom scroll container with real content

The fundamental trick behind list virtualization is that the scrollbar must behave as if all items exist, while the DOM only contains the items currently visible. The demo achieves this with a two-layer div structure. The outer div has overflow: auto and a fixed height it is the actual scroll container.

Inside it sits a div whose height is set to totalHeight, which is the estimated sum of all item heights. This tall inner div makes the scrollbar track as long as it would need to be for all items. Then, inside that inner div, the actually rendered items are positioned absolutely at top: offsetY, where offsetY is the pixel offset of the first rendered item.

When the user scrolls, only scrollTop changes; the component recomputes which items fall in the visible range and repositions the absolute container. The browser never knows that items outside the window do not exist as DOM nodes.

Height measurement and the ResizeObserver feedback loop

For fixed-height lists, the position of any item can be calculated as index * itemHeight in O(1). Variable heights break this entirely because you cannot know the offset of item N without summing the heights of items 0 through N-1.

The useVirtualization hook stores measured heights in a Map keyed by index and falls back to averageHeight for items not yet measured. The MeasuredItem wrapper component reads its DOM element's getBoundingClientRect().height in a useEffect and reports it to the hook via setItemHeight.

Critically, it also attaches a ResizeObserver so that if the content of an item changes after first render for instance because an image loads or text truncation is toggled the height map is updated and the total height recalculates. Without this, items that grow after initial measurement would overlap each other as the virtual offsets drift from the true positions.

Why useMemo is load-bearing here, not just an optimisation

In most components, wrapping a computation in useMemo is a performance hint. In useVirtualization, it is architecturally necessary. The startIndex, endIndex, offsetY, and totalHeight values all perform linear scans over the entire item count. At 100,000 items, each scan is not free.

If these computations ran on every render including renders caused by unrelated state changes like the search input updating the scrolling experience would degrade noticeably. By memoising on [scrollTop, containerHeight, itemCount, averageHeight, overscan], the expensive scans only re-run when the scroll position or the item count changes.

The averageHeight dependency is particularly important: as more items are measured, averageHeight converges to the true mean and the memoised values are recomputed, propagating more accurate position estimates throughout the list.

💡 Key Code Explained

const MeasuredItem = ({ index, children, setItemHeight }) => {
  const ref = useRef(null);

  useEffect(() => {
    if (ref.current) {
      const height = ref.current.getBoundingClientRect().height;
      setItemHeight(index, height);
    }
  }, [index, setItemHeight]);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const resizeObserver = new ResizeObserver(() => {
      const height = element.getBoundingClientRect().height;
      setItemHeight(index, height);
    });

    resizeObserver.observe(element);
    return () => resizeObserver.disconnect();
  }, [index, setItemHeight]);

  return <div ref={ref}>{children}</div>;
};

Two separate effects handle two distinct measurement moments. The first effect fires after the initial render and records the item's height before any async content (images, fonts) has loaded.

The second effect sets up a ResizeObserver to catch any height change that happens after that initial measurement. Without the observer, expanding a row (toggling a details section, loading an image) would not update the offset map, causing subsequent items to render at the wrong vertical position.

A junior developer would often write only the initial useEffect and wonder why items overlap after dynamic content loads. The resizeObserver.disconnect() cleanup in the return function is also critical without it, every item that has ever been rendered keeps its observer alive, leaking memory proportional to the total number of items rendered over the session.

const { startIndex, endIndex, offsetY } = useMemo(() => {
  let currentOffset = 0;
  let start = 0;

  for (let i = 0; i < itemCount; i++) {
    const itemHeight = itemHeights.current.get(i) ?? averageHeight;
    if (currentOffset + itemHeight > scrollTop) {
      start = Math.max(0, i - overscan);
      break;
    }
    currentOffset += itemHeight;
  }

  let startOffset = 0;
  for (let i = 0; i < start; i++) {
    startOffset += itemHeights.current.get(i) ?? averageHeight;
  }

  let viewportBottom = scrollTop + containerHeight;
  let tempOffset = startOffset;
  let end = 0;
  for (let i = start; i < itemCount; i++) {
    const itemHeight = itemHeights.current.get(i) ?? averageHeight;
    if (tempOffset > viewportBottom) {
      end = Math.min(itemCount - 1, i + overscan);
      break;
    }
    tempOffset += itemHeight;
    end = i;
  }

  return {
    startIndex: start,
    endIndex: Math.min(end + overscan, itemCount - 1),
    offsetY: startOffset,
  };
}, [scrollTop, containerHeight, itemCount, averageHeight, overscan]);

This is an O(n) scan in the worst case a real production implementation would use a prefix-sum array or a Fenwick tree to reduce this to O(log n). However, for the demo's purposes the approach is instructive because it makes the algorithm transparent.

The key insight is that the loop breaks as soon as it finds the first item that straddles scrollTop, rather than scanning to the end. The overscan buffer adds items above and below the visible window so that fast scrolling does not flash blank space before the next render completes.

Using averageHeight as the fallback for unmeasured items means the estimates improve continuously as items enter and leave the window — the hook self-corrects over time.

⚖️ Tradeoffs

ApproachProCon
Custom virtualization hook (chosen)Full control; educational; no library dependencyO(n) scan per scroll event; no Fenwick tree for fast offset lookup; must handle edge cases manually
react-window FixedSizeListExtremely fast O(1) offset calculation; battle-testedAll items must have the same height; no dynamic measurement
react-window VariableSizeListO(1) with precomputed heights; library-maintainedHeights must be provided upfront or estimated; no automatic measurement
TanStack Virtual (react-virtual)Hooks-based; measures items automatically; supports both fixed and variableHigher learning curve; more configuration
No virtualization (render all items)Simplest codeCatastrophic performance above ~1000 complex items; initial render time in seconds for 100k rows

🎯 What Interviewers Actually Check

  • Explains that the container div height must equal the estimated total height so the scrollbar renders at the correct length, even before all items are measured
  • Knows that ResizeObserver is necessary not just useEffect on mount for items whose height can change after first render
  • Can articulate why averageHeight as a fallback for unmeasured items causes the list to self-correct: as more items are measured, the average converges and offset errors shrink
  • Mentions that the O(n) linear scan should be replaced by a prefix-sum array or binary search in a production implementation
  • Understands that the overscan value is a UX tradeoff: higher overscan means smoother fast scrolling but more DOM nodes at any given time

❓ Follow-Up Questions

  1. The current implementation uses an O(n) linear scan to find startIndex. Describe how you would replace this with a Fenwick tree (binary indexed tree) to achieve O(log n) lookups, and what additional bookkeeping that requires when item heights change.
  2. When the user types in the search input, filteredItems changes length and the scroll position stays at its previous pixel offset. This can put the viewport in the middle of a completely different section of the filtered list. How would you fix this to always jump to the top on search?
  3. How would you add support for a sticky group header that stays visible while scrolling through items in that group, the way a contacts list shows the letter "A" while you scroll through Alice, Adam, etc.?
  4. The demo generates 100,000 items in JavaScript in generateDataset. In a real app, would you ever generate the full dataset on the client? What server-side changes would you make to support true infinite scrolling with virtualization?
  5. A designer asks you to add a row expansion feature where clicking an item expands it to show more detail, doubling or tripling its height. Walk through every part of the virtualization system that needs to update when a row expands.

🎮 Live Demo: Virtualized List for 100K+ Items

📝 Summary

Virtualizing a 100,000-item list is fundamentally an exercise in deceiving the browser's scrollbar while keeping the DOM small. The two-layer container structure a tall phantom div that establishes scroll range, with absolutely positioned rendered items inside it is the core mechanism that makes windowing possible.

Variable heights introduce the hardest engineering challenge: you cannot know where item N starts without measuring all items before it, so the system must operate on estimates that improve continuously as items are scrolled into view.

The ResizeObserver is what upgrades a one-shot measurement into a live feedback loop, catching height changes that happen after initial render.

In production, the O(n) height-scanning should be replaced with a prefix-sum array for O(1) lookups, and a battle-tested library like react-window or TanStack Virtual is the correct default the custom implementation shown here is primarily useful for understanding exactly what those libraries do internally.

Frequently Asked Questions

Why virtualize lists?

To keep the DOM small and rendering fast; rendering 100k DOM nodes is prohibitively expensive.

Which library to use?

Use battle-tested libraries like react-window or react-virtual for fixed/variable height lists. For complex use-cases consider custom solutions.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement