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:
- A scrollable container with a fixed height (or a known max height).
- An inner element sized to the total list height (creates the scrollbar).
- 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 bymeasureElement.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:
- Uses
estimateSizefor initial layout (all rows assumed 60px). - When a row mounts,
measureElementreadsgetBoundingClientRect().heightand caches it. - 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-rowcounton the list andaria-rowindexon 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+Fbrowser 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: stickydoesn’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
keyto the virtualizer or reset measurements. overscantoo 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: autois the CSS-only cheap win — no library needed.” - “Variable heights need
measureElementto cache real heights; pick a goodestimateSizefor 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-rowindexso 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
- Infinite feed (pairs with virtualization): ../14_frontend_system_design/02_infinite_feed.md
- Pagination and infinite queries: ../11_apis_data_fetching/04_pagination_and_infinite_queries.md
- Accessibility: ../16_accessibility/
Further reading
- TanStack Virtual: https://tanstack.com/virtual/latest
react-window: https://github.com/bvaughn/react-windowvue-virtual-scroller: https://github.com/Akryum/vue-virtual-scroller- web.dev —
content-visibility: https://web.dev/articles/content-visibility