10 min read

Virtualization

You can't make 100,000 rows cheap. You can make sure only the 20 the user can see actually exist.


The story

An internal admin tool shows every shipment for the month in one table: about 50,000 rows. The first version renders all of them. It works while the dataset is small. By the end of the quarter:

  • Opening the page takes 6 seconds on a good laptop, most of it in one React render and one giant layout.
  • The tab uses over 500 MB of memory.
  • Selecting a row (which re-renders the table to highlight it) freezes the page for almost a second.

The quick suggestion is pagination: 50 rows per page. The operations team rejects it. They scroll through the day's shipments looking for anomalies, they use Ctrl+F to find a code, and they want "jump to row 34,000".

The team virtualizes the table instead. Now only the rows in the viewport, plus a few above and below, exist in the DOM: about 30 rows, whatever the dataset size. The page opens in under 300 ms, memory drops by more than 80%, and selection is instant.

Then the bug reports arrive: Ctrl+F no longer finds rows that are off-screen, a screen reader announces "list, 30 items" for a 50,000-row table, and a keyboard user who tabs to a row and scrolls away loses their focus.

That's virtualization in one story: a huge performance win, and a set of costs you have to pay back deliberately.


Part 1: Why many DOM nodes are expensive

Every element costs the browser work and memory, and most of that work scales with the total number of elements, not just the visible ones:

A useful reference point: Lighthouse warns about "excessive DOM size" at around 800 elements in the body and flags it as a problem at around 1,400. A data table can blow through that in its first screen.

Note what is not on that list: scrolling. Once a static list is rendered, scrolling it is cheap: the compositor moves already-painted layers, usually without touching the main thread. Big lists are expensive to create, to update and to keep in memory, not to scroll. Virtualization trades a bit of work on every scroll frame for avoiding all of that.


Part 2: The windowing idea

Instead of rendering every row, render a tall empty "spacer" so the scrollbar looks right, and place only the visible rows inside it:

The core is a few lines:

js
const ROW = 36, OVERSCAN = 6
function visibleRange(scrollTop, viewportHeight, count) {
  const first = Math.max(0, Math.floor(scrollTop / ROW) - OVERSCAN)
  const last = Math.min(count, Math.ceil((scrollTop + viewportHeight) / ROW) + OVERSCAN)
  return [first, last]
}
// render rows[first..last), each with style={{ position: 'absolute', top: i * ROW }}
// inside a div with style={{ height: count * ROW, position: 'relative' }}

Overscan renders a few extra rows above and below the viewport, so fast scrolling doesn't reveal blank space before the next render catches up. More overscan means fewer blank flashes and more work per frame. 3–10 rows is typical.

The scroll loop

Scrolling itself still happens on the compositor. Virtualization only needs the main thread to keep up with new rows entering the window, which is a small, bounded amount of work per frame. Keep row components cheap (memo, stable keys, no heavy work in render) so it stays that way.


Part 3: Try it

The simulation below is a real list in this page. "Render everything" really creates every row. The other two tabs use a small windowing implementation, not a library. Watch the element count, then try the highlight update and the auto-scroll in each tab.

Loading simulation…

Part 4: Variable heights

Fixed-height rows make the math trivial. Real content rarely cooperates: a note that wraps to three lines, an expanded row, a chat message with an image.

The virtualizer can't know a row's height until it has rendered it. So it:

  1. Estimates every row (say 52 px) to size the spacer.
  2. Measures each row when it mounts (getBoundingClientRect or a ResizeObserver) and caches the real height.
  3. Keeps a prefix-sum array of heights, so "which row is at scroll position y?" is a binary search, and "where does row i start?" is a lookup.
  4. Corrects the scroll position when a row above the viewport turns out taller or shorter than estimated. Otherwise the content the user is looking at jumps.

The visible side effect: the scrollbar thumb changes size and drifts slightly as rows are measured. That's normal. Good libraries make it barely noticeable.

Reverse lists (chat, logs) add one more twist: they start scrolled to the bottom and load older items above. Each prepend must keep the user's place by adjusting scrollTop by the height of what was added. The CSS overflow-anchor property helps in non-virtualized lists, but virtualizers usually manage it themselves.


Part 5: Grids, sticky headers and frozen columns

A spreadsheet-style grid virtualizes in both directions: only the visible rows and visible columns render. 1,000,000 cells become a few hundred.

  • Sticky headers and frozen columns work with position: sticky inside the scroll container, as long as they're rendered regardless of the window.
  • Column virtualization needs known (or measured) column widths, the same way rows need heights.
  • Past a certain size, grids leave the DOM entirely. Google Sheets renders its grid with canvas, and Google Docs moved its document rendering to canvas in 2021. Chapter 6 covers why and how.

Part 6: Getting the data — infinite scroll and pagination

Virtualization is about rendering. You also have to decide how rows arrive:

ApproachBest forWatch out for
Load everything then virtualizeUp to tens of thousands of rows that fit comfortably in memoryInitial download size. Parse it in a worker (Chapter 3).
Infinite scroll (load the next page near the bottom)Feeds, search resultsFooter unreachable, losing your place on back-navigation, unbounded memory
Windowed fetching (load pages for whatever range is visible)Huge tables with "jump to row 34,000"Placeholder rows while pages load. Needs a total count.
Classic paginationResults people refer to by page, SEO pagesIt's fine. Not everything needs to scroll.

Implementation notes:

  • Use cursor pagination (?after=SHP-48213) rather than OFFSET, which gets slower the deeper you go and skips or repeats rows when data changes.
  • Trigger loading with an IntersectionObserver on a sentinel element near the end, not a scroll handler.
  • For very long sessions, evict pages far from the viewport, so memory doesn't grow forever.

Part 7: The cheaper alternative — content-visibility

For long documents rather than data tables (a long article, a settings page, a feed of a few hundred cards), CSS can do a lighter form of this:

css
.card {
  content-visibility: auto;
  contain-intrinsic-size: auto 240px;   /* placeholder size until it's rendered */
}

The browser skips style, layout and paint for off-screen cards, but the elements still exist in the DOM. So Ctrl+F, anchor links and screen readers keep working, at the cost of memory and the framework's render work. It's a good first step for hundreds of items. It doesn't help with 100,000. See FE-06 for containment in general.


Part 8: Paying back the costs

Virtualization removes things the browser normally gives you for free. Put them back deliberately:

LostPut it back
Ctrl+F finds only rendered rowsProvide in-app search that filters or jumps to the row. Mention it in the UI.
Screen reader counts ("list, 30 items")aria-setsize / aria-posinset on list items. aria-rowcount / aria-rowindex on grids.
Focus disappears when the focused row scrolls outKeep the focused row rendered even outside the window, or move focus to the container
Keyboard navigation past the rendered rowsHandle arrow keys yourself, scroll the target into the window, then focus it
Scroll restoration on back-navigationStore scrollTop (or the first visible row's ID) and restore it after data loads
Print / "select all and copy"A separate export or print view

Numbers to remember

NumberMeaning
~800 / ~1,400Lighthouse's "excessive DOM size" warning and error thresholds (body elements)
20–40Rows a virtualized list typically keeps in the DOM, at any dataset size
3–10Typical overscan rows on each side
HundredsWhere content-visibility stops being enough and you need real virtualization
~33.5M pxRoughly where browsers cap element height (varies). Huge row counts × row height can hit it, and libraries then scale the scroll position.

The DevTools workflow

  1. Count elements: document.querySelectorAll('*').length in the console, or the Performance monitor panel's "DOM Nodes" graph while you scroll.
  2. Performance panel, CPU 4×: record opening the page and an update (selection, sort). A huge "Recalculate Style" or "Layout" block with tens of thousands of elements affected is the signature of a too-big DOM.
  3. Record a fast scroll in a virtualized list. Each frame's main-thread work should be small. Long frames during scroll mean row components are too expensive or the window recomputes too much.
  4. Rendering → Frame rendering stats shows dropped frames while you scroll.
  5. Memory panel: compare heap size before and after virtualizing.
  6. Test Ctrl+F, Tab, and a screen reader on the virtualized version before shipping.

Cheat sheet

SituationDo
Up to a few hundred itemsPlain list. Maybe content-visibility: auto.
Thousands to millions of rowsVirtualize (TanStack Virtual, react-window, react-virtuoso)
Fixed row heightSimple math: index × height
Variable heightsEstimate, measure on mount, prefix sums + binary search, scroll correction
Chat / logs (newest at bottom)Reverse virtual list. Keep position on prepend.
Spreadsheet-like gridVirtualize rows and columns. Sticky header and frozen column.
Millions of cells, heavy stylingConsider a canvas-rendered grid (Chapter 6)
Blank flashes when scrolling fastMore overscan, cheaper rows, placeholder skeleton rows
Data from a serverCursor pagination + IntersectionObserver, evict distant pages
Accessibilityaria-setsize/posinset or rowcount/rowindex, keep focused row mounted, in-app search

Say it in the interview

"At 50,000 rows I'd virtualize: a spacer sized to the full list and only the visible rows plus some overscan absolutely positioned inside it, so the DOM stays around 30 rows at any size. Row heights vary, so I'd estimate, measure on mount, keep prefix sums for binary search, and correct scrollTop when rows above the viewport change size. Then I'd pay back what virtualization breaks: in-app search for Ctrl+F, aria-rowcount and rowindex for screen readers, and keeping the focused row mounted."


Go deeper