frontend / performance / 06_list_virtualization.md

List Virtualization

6 min read source

List Virtualization

TL;DR

Rendering 10,000 DOM nodes is fatal — memory, scroll perf, even first paint. Virtualization renders only what’s in (or near) the viewport, with a sized scroller giving the illusion of the full list. The senior tools are @tanstack/react-virtual (or react-window) for React, vue-virtual-scroller for Vue, and content-visibility: auto as a cheap CSS-only intermediate step. Variable-height rows need measurement caching to avoid scroll jumps.

Interview Q&A

Q: When do you reach for virtualization?

A: Roughly:

List size Strategy
< 100 items Render all — virtualization overhead isn’t worth it
100–1000 items IntersectionObserver to lazy-render rows; content-visibility: auto
> 1000 items Virtualize
> 10,000 items Definitely virtualize; consider server-side pagination instead

Other triggers: rich rows (each item has images, complex layout) — even 200 items can be too much.

Q: How does virtualization work conceptually?

A: Three pieces:

  1. A scrollable container with a fixed height (or a known max height).
  2. An inner element sized to the total list height (creates the scrollbar).
  3. Absolutely positioned rows for only the visible items, translated to their correct Y offset.
┌─ container (overflow: auto, height: 600px) ────┐
│ ┌─ inner (height: 50,000px) ───────────────┐  │
│ │                                          │  │
│ │  [visible row 1 at top: 12,000px]        │  │
│ │  [visible row 2 at top: 12,050px]        │  │
│ │  [visible row 3 at top: 12,100px]        │  │
│ │                                          │  │
│ └──────────────────────────────────────────┘  │
└────────────────────────────────────────────────┘

Scroll events update which rows are in viewport; only those mount in the DOM.

Q: React example with @tanstack/react-virtual.

A:

import { useVirtualizer } from "@tanstack/react-virtual";

function VirtualList({ items }: { items: Item[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 60,           // px per row (estimate for variable)
    overscan: 5,                      // render 5 above/below viewport
  });

  return (
    <div ref={parentRef} style={{ height: 600, overflow: "auto" }}>
      <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
        {virtualizer.getVirtualItems().map((v) => (
          <div
            key={v.key}
            data-index={v.index}
            ref={virtualizer.measureElement}
            style={{
              position: "absolute",
              top: 0,
              left: 0,
              width: "100%",
              transform: `translateY(${v.start}px)`,
            }}
          >
            <Row item={items[v.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

Key options:

  • count — total item count.
  • estimateSize — best-guess row height; refined by measureElement.
  • overscan — buffer rows above/below viewport so fast scroll doesn’t show blank space.
  • measureElement — measures each row on mount, caches for accurate positioning.

Q: Variable-height rows — what’s the trick?

A: Estimated heights position rows; measured heights correct future positioning. The virtualizer:

  1. Uses estimateSize for initial layout (all rows assumed 60px).
  2. When a row mounts, measureElement reads getBoundingClientRect().height and caches it.
  3. Subsequent renders use the cached real heights for absolute positioning, falling back to estimate for unmeasured rows.

The bug to avoid: scroll jumps when measured heights differ from estimates. Fix: provide a good estimate (median of typical rows), and accept some jitter on first scroll-through of the full list.

For long lists with mostly-similar heights, fixed-height (estimate matches reality) gives the smoothest scroll. Variable heights need either careful estimates or pre-measured heights from the server (item.height).

Q: Vue equivalent.

A: vue-virtual-scroller:

<script setup>
import { RecycleScroller } from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
</script>

<template>
  <RecycleScroller
    :items="items"
    :item-size="60"
    key-field="id"
    v-slot="{ item }"
    style="height: 600px"
  >
    <Row :item="item" />
  </RecycleScroller>
</template>

DynamicScroller handles variable heights with measurement caching.

@tanstack/vue-virtual is the same primitive as the React version, slightly more flexible but DIY-ish.

Q: What’s content-visibility: auto and when does it beat virtualization?

A: A CSS property that tells the browser to skip rendering (layout + paint) of an element until it’s near the viewport. No JS required.

.row {
  content-visibility: auto;
  contain-intrinsic-size: 60px 100%;    /* placeholder size */
}

Pros:

  • Zero JS overhead.
  • Works for arbitrary HTML — no need to wrap in a virtualizer.
  • Easy retrofit.

Cons:

  • All DOM nodes still exist (memory cost).
  • Doesn’t help with parse time.
  • Less control over what’s rendered.

Best for: lists in the 100-1000 range, especially when each row is “simple but lots.” Above ~1000-2000 items, virtualization wins on memory.

Q: Virtualization + horizontal scroll + grids?

A: Same principle in 2D. react-window has FixedSizeGrid/VariableSizeGrid; TanStack Virtual supports both axes via two virtualizers. Image galleries with thousands of items use grid virtualization.

const rowVirt = useVirtualizer({ count: rowCount, ... });
const colVirt = useVirtualizer({ count: colCount, horizontal: true, ... });

// Render only the visible cell intersections

Q: Infinite scroll + virtualization — do they conflict?

A: No — they compose. Infinite scroll loads more data (more items in the array); virtualization renders only the visible slice. Together:

  • Initial: 50 items loaded, ~10 rendered (virtualized).
  • Scroll to end: fetch next 50, total 100 items, still ~10 rendered.
  • Scroll back: virtualizer re-renders earlier slice (cached data, fresh DOM).

The “loading more” trigger lives in the virtualizer’s getVirtualItems() — when the last visible index approaches count, fire fetchNextPage(). See ../14_frontend_system_design/02_infinite_feed.md.

Q: Accessibility concerns with virtualization?

A: Big ones:

  • Screen readers may announce “list of 10 items” when there are 10,000 — because only 10 are in the DOM. Set aria-rowcount on the list and aria-rowindex on each row to announce true counts.
  • Tab navigation skips off-screen items because they’re not in the DOM. Implement keyboard navigation manually (arrow keys + roving tabindex) that scrolls to keep focus visible.
  • Ctrl+F browser find won’t find off-screen items — they’re not in the DOM. Provide an in-app search instead.
<div role="grid" aria-rowcount={items.length}>
  {virtualizer.getVirtualItems().map((v, i) => (
    <div role="row" aria-rowindex={v.index + 1}>...</div>
  ))}
</div>

For accessible virtualized lists, library support varies — read each library’s a11y docs.

Gotchas / edge cases

  • Bad estimates = scroll jumps. Measure a sample, pick the median.
  • Items with images loading lazily — image load changes row height after measurement, causing layout shift. Reserve aspect ratio via CSS.
  • position: sticky doesn’t work nicely inside absolutely-positioned rows — sticky needs scroll-context awareness. Workarounds exist but are fragile.
  • Scroll restoration — browsers restore scroll position by pixel offset; virtualized lists may not have the same content at that pixel after reload. Save the first visible index (or cursor) and re-scroll to it.
  • Filter / sort changes invalidate measured heights — pass a key to the virtualizer or reset measurements.
  • overscan too low = blank rows on fast scroll; too high = wasted render. 3-10 is the sweet spot.
  • Mobile momentum scroll can outpace render — virtualizers handle this, but heavy row components show blanks.

What a senior is expected to say

  • “Virtualize above ~1000 items. For 100-1000, content-visibility: auto is the CSS-only cheap win — no library needed.”
  • “Variable heights need measureElement to cache real heights; pick a good estimateSize for the initial layout to minimize scroll jumps.”
  • “Overscan 3-10 rows so fast scroll doesn’t show blanks. Pair with infinite query for data-loading, virtualization for DOM-rendering.”
  • “Accessibility: aria-rowcount + aria-rowindex so screen readers know the true list size; keyboard navigation needs manual focus management since off-screen items aren’t in the DOM.”
  • “Don’t reach for virtualization until you’ve measured the cost. Adding it has complexity overhead — accessibility, scroll restoration, focus, find-in-page.”

Cross-references

Further reading