Article · Personal blog
Virtualizing long lists in React without the pain
Render windows, height measurement and scroll anchoring — what it really takes to scroll thousands of items lag-free.
In Mail.ru Cloud a user can open a folder with ten thousand files — and expects it to scroll like an ordinary list. Here is what smooth scrolling actually requires: a render window, honest height measurement and scroll anchoring.
The DOM is not infinite
A browser comfortably holds a couple of thousand simple nodes. But a file-manager row is not one node: an icon, a name, metadata, a checkbox, an actions menu. Multiply that by 10,000 rows — and you get hundreds of thousands of elements that have to be styled, repainted and measured on every layout change.
The symptoms are familiar to everyone: a first paint that takes seconds, stuttering scroll, a “frozen” tab when you select all items. Meanwhile the screen shows maybe 30 rows at a time. Everything else is dead weight.
Virtualization is a deal with the browser: we only paint what is visible, and in return we promise to account for everything else correctly.
The render window
The core idea fits in a few lines: from the scroll position, compute the range of visible indices, render only that range, and offset the container by the height of the “skipped” rows.
// fixed row height — the simplest case
function useWindow(count, rowH, viewportH, scrollTop) {
const OVERSCAN = 6; // headroom above and below
const first = Math.floor(scrollTop / rowH) - OVERSCAN;
const last = Math.ceil((scrollTop + viewportH) / rowH) + OVERSCAN;
const start = Math.max(0, first);
const end = Math.min(count, last);
return { start, end, offsetY: start * rowH, totalH: count * rowH };
}
The container gets height: totalH so the scrollbar reflects the real size of the list, and the visible rows get transform: translateY(offsetY). A few rows of overscan remove the “flash” of emptiness during fast scrolling.
Ready-made libraries — react-window, virtua, @tanstack/virtual — do exactly this. If your row heights are fixed, pick any of them and don’t write your own. The hard part starts later.
Dynamic heights
Real content never comes in one height: long names wrap onto two lines, images have their own proportions, group headers sit between sections. You can’t know a height in advance — you can only measure it after render.
The working scheme: keep an estimated height for unmeasured rows, then refine it after mount via ResizeObserver and recompute positions.
const measure = useCallback((node, index) => {
if (!node) return;
observer.observe(node);
// store the real height and mark the position cache dirty
heights.current.set(index, node.offsetHeight);
invalidateFrom(index);
}, []);
The main trap is recomputing the positions of all rows on every measurement. On large lists that’s O(n) per scroll frame. The fix: compute prefix sums lazily and cache the “clean” range — only the tail after the changed row gets recomputed.
Scroll anchors
When a row above the viewport changes height — an image loads in, a group expands — everything below it jumps. The browser’s overflow-anchor doesn’t work with virtualization: the nodes it could “anchor” to are the very ones we remove.
You have to hold the anchor yourself: remember the index of the top visible row and its offset relative to the viewport, then after recomputing heights restore scrollTop so that row stays in place. The user should never notice that the whole list “moved” underneath them.
- The anchor updates only on user actions — scrolling and keyboard navigation.
- Programmatic changes (data loading, resize) never move the anchor — they adjust around it.
- Restore
scrollTopin the same frame as the recompute — otherwise one frame of “jitter” is still visible.
Accessibility
Virtualization breaks exactly what screen readers rely on: only a window of ~40 rows exists in the DOM. The minimum worth doing:
role="listbox"orrole="grid"on the container andaria-rowcountwith the real list size;aria-rowindexon every visible row — so the screen reader announces “row 4,211 of 10,000”;- focus management: arrow keys move the active index, not DOM focus across nodes that are about to unmount;
Ctrl+A,Home/End,PageUp/PageDownoperate on the data, not on the DOM.
The checklist
In short — here is what I verify before calling a virtualized list done:
- the scrollbar reflects the real size of the list, not the window;
- fast flick-scrolling never shows empty gaps;
- a height change above the viewport doesn’t shift the visible content;
- position recomputation stays out of the scroll hot path (verified with a profiler);
- keyboard navigation and screen readers know the full list size;
- at 10× the data, memory degrades — FPS doesn’t.
In Cloud this set of decisions removed the lag when scrolling folders with thousands of files and, as a bonus, cut a quarter of the initial JS — the heavy cells moved into lazy chunks because they no longer had to be rendered “just in case”.