A React table renders 10,000 rows and scrolling stutters. Explain what list virtualization (windowing) changes about what is in the DOM, and how the scrollbar can still represent the full list.
answer
- viewport, not data length
- only the visible slice is mounted
- something must hold the scroll height
- offset each row to its real position
- overscan absorbs fast scrolling
basics
~20 sWindowing renders only the rows that intersect the scroll viewport plus a small overscan, so dozens of row elements exist instead of ten thousand. An inner spacer element sized to the full content height keeps the scrollbar and every scroll offset accurate.
solid answer
~50 sWindowing renders only the rows intersecting the scroll viewport plus a few overscan rows, so a 10,000-row list keeps roughly 20-40 row elements alive instead of 10,000. There are three moving parts. A scroll container with a fixed height and `overflow: auto` owns `scrollTop`. Inside it, a spacer element is sized to the total content height — `rowCount * rowHeight` for fixed rows — so the scrollbar thumb and any programmatic scroll offset behave as if every row were present. The rendered rows are taken out of flow and offset to `index * rowHeight`, usually with `position: absolute` plus a `translateY`. On each scroll the virtualizer reads `scrollTop`, derives the first visible index and how many rows fit, and renders that slice. React then reconciles about 30 elements per update instead of 10,000. react-window and TanStack Virtual package exactly this.
code
javascript · 10 linesfunction visibleRange(scrollTop, viewportHeight, rowHeight, rowCount, overscan) {
const first = Math.floor(scrollTop / rowHeight);
const visibleCount = Math.ceil(viewportHeight / rowHeight) + 1;
const start = Math.max(0, first - overscan);
const end = Math.min(rowCount - 1, first + visibleCount + overscan);
return { start, end, totalHeight: rowCount * rowHeight };
}
console.log(visibleRange(2000, 600, 40, 10000, 3));
// { start: 47, end: 69, totalHeight: 400000 }go deeper
Be able to say plainly that only the rows in view are put in the DOM, that a sized spacer preserves the scroll height, and that react-window or TanStack Virtual do this for you.
Explain the arithmetic from scrollTop and row height, why each row is absolutely positioned at its own offset, and why React's commit cost now scales with the viewport rather than the data.
Show that you measured first — DOM node count, commit duration, long tasks during scroll — and can name what windowing does not fix: payload size, per-row work, and state that lives inside a row that gets unmounted.
Own the downstream consequences: find-in-page, deep links to a row, printing and screen-reader counts all change once the DOM holds only a slice, so treat virtualization as a product decision with an ongoing support cost.
## Why 10,000 rows is slow Every rendered row is real DOM: elements to create, styles to compute, boxes to lay out and paint, plus a React fiber per element and a mount/cleanup cycle for any effect the row runs. That cost scales with the size of the data, not with what a human can see. A viewport shows maybe 20 rows; the other 9,980 pay for themselves in memory, in layout time on every reflow, and in one very long commit on first render. The virtual DOM does not save you here — diffing 10,000 elements is itself the work, and the browser still has to build 10,000 real nodes underneath it. Virtualization (also called windowing) inverts the relationship: the number of live rows becomes a function of viewport height, and the data length stops mattering. ## The three moving parts **1. A scroll container.** An element with a bounded height and `overflow: auto`. It is the thing that scrolls, and its `scrollTop` is the single input the whole system reads. If this element has no fixed height, or the page scrolls instead of the container, the virtualizer has nothing to measure — that is the most common setup mistake. Window-level scrolling is supported by libraries too, but then the scroll source is the document rather than a div. **2. A spacer, or sizer, element.** A child of the container whose height is the *total* content height: `rowCount * rowHeight` for uniform rows, or the sum of all row heights when they vary. Nothing is rendered inside it directly. Its only job is to make the container scrollable to the right distance. Without it, the scrollbar reflects only the handful of rendered rows: the thumb is huge, the user reaches the bottom after one flick, and programmatic scroll offsets mean nothing. **3. The windowed rows.** The slice that is actually rendered, positioned absolutely inside the spacer at the offset each row would occupy if all rows existed. Libraries hand you a `style` object per row carrying that offset (and, for fixed lists, the height) — forgetting to spread it onto your row element produces the classic bug where every row stacks at the top of the container. ## The range math For uniform row heights the arithmetic is closed-form — no measuring, no loops: ```js const first = Math.floor(scrollTop / rowHeight); const visibleCount = Math.ceil(viewportHeight / rowHeight) + 1; // +1 for a partial row const start = Math.max(0, first - overscan); const end = Math.min(rowCount - 1, first + visibleCount + overscan); ``` The `+ 1` matters: a viewport rarely divides evenly into rows, so one row is partially visible at the bottom edge and must be rendered or the last few pixels are blank. `overscan` widens the window on both sides so fast scrolling does not outrun rendering. When heights vary, the same idea holds but the index lookup is no longer division — you keep a prefix-sum array of offsets and binary-search it, which is why variable-height virtualization is a materially harder problem. ## Where React fits React's contribution is that the render output is now a small array. Reconciliation, commit duration and effect churn all scale with the window, so they stop growing with the data. In exchange, rows mount and unmount constantly during scrolling. Treat a row as a pure projection of its data: any state a row holds locally — an open menu, an inline edit, a text selection — disappears when it scrolls out of the window, so that state must live in the parent or in the data model. ## What virtualization does *not* fix - **Payload and memory of the data itself.** You still fetched and still hold 10,000 records. If the network response is the problem, you need pagination or server-side windowing, not windowing of the DOM. - **Expensive per-row work.** Rendering 30 rows that each run a heavy computation is still slow; windowing multiplies your per-row cost by a smaller number, it does not reduce it. - **DOM-dependent browser features.** Native find-in-page, print, and select-all-and-copy only see what is mounted. ## The libraries Interviewers want the concept first and the library second. `react-window` is the small, opinionated option: `FixedSizeList` and `VariableSizeList` take `itemCount`, `itemSize`, `height`, `width` and a render-prop child that receives `{ index, style }`. `@tanstack/react-virtual` is headless: `useVirtualizer({ count, getScrollElement, estimateSize, overscan })` returns `getVirtualItems()` and `getTotalSize()`, and you own all the markup. Saying that you would reach for one rather than hand-roll it — because measurement, scroll restoration and resize are where hand-rolled versions break — is a strong close.
- The correct rows render, but they all pile up at the top of the container instead of spreading down the list. What is wrong?The per-row offset is not being applied. Virtualizers hand each row a `style` (or a computed start offset) that positions it absolutely at `index * rowHeight`; if you drop that style, or the sizer is not a positioned ancestor, every absolutely positioned row lands at offset zero. Spread the style onto the row's outermost element and give the sizer `position: relative`.
- Does virtualizing the list also reduce how much data the page downloads?No. Windowing is purely a rendering optimization — the client still fetched and still holds every record in memory. If the payload or the JSON parse is the bottleneck, you need server-side pagination, cursor-based loading, or a narrower field set. Virtualization pairs well with those, but it does not replace them.
- A row keeps an inline-edit input open; after scrolling away and back, the edit is gone. Why?Rows outside the window are unmounted, so any state held inside the row component is destroyed with it. Lift that state to the list owner, keyed by the record's id, so the row renders as a pure projection of the data it is given. This is a general rule for virtualized rows: they must survive being thrown away and recreated.
saying these in an interview costs you the question
- Says offscreen rows are just hidden with display:none
- Claims React virtualizes long lists automatically
- Omits the spacer, so the scrollbar only spans rendered rows
- Confuses virtualization with pagination or infinite scroll
- Believes the virtual DOM already makes 10,000 rows cheap