PerformanceGENERALFRAMEWORK-SPECIFICPLATFORM-SPECIFIC

List Virtualization

A hundred thousand rows of data, about thirty rows of DOM. It is the right answer for large lists and it costs you real things.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

The table has 100,000 rows and the page is unusable. What do I actually do?

The user intent

Someone needs to work through a large dataset — scan it, search it, sort it — and expects scrolling to be smooth and the page to appear promptly.

The obvious build

Render all the rows. The browser is good at scrolling; the data is only text.

Why it breaks

Style and layout cost scale with node count, and a hundred thousand rows is easily a million nodes once each row has cells and controls (Style Calculation).

How it breaks in a real browser
  • Style and layout cost scale with node count, and a hundred thousand rows is easily a million nodes once each row has cells and controls (Style Calculation).
  • Memory scales with them too, so the tab becomes a candidate for discard on a modest device (Memory Leaks).
  • Initial render is one enormous task on the main thread, so the page is frozen while it happens — no input, no frames (Long Tasks).
  • Every subsequent update has to consider a huge tree, so even a small change becomes expensive (Style Invalidation).
  • Nobody reads a hundred thousand rows. Almost all of that work produces pixels no one will ever look at.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • Virtualization renders only the rows intersecting the viewport, plus a small overscan buffer above and below so scrolling does not reveal blank space.
  • The scrollbar is kept honest by a spacer: a container sized to the full list height, with the visible window positioned inside it. The browser therefore still believes the list is its real length.
  • As the user scrolls, the window is recomputed and the same handful of DOM nodes are reused with different content rather than created and destroyed.
  • Row height is the crux. Fixed heights make the mapping from scroll position to index arithmetic. Variable heights require measurement, which means rendering to find out how tall something is — and that measurement feeds back into the scroll geometry (Layout Thrashing).
  • The data stays whole; only the DOM is windowed. Sorting, filtering and selection operate on the array, not on nodes (What a Mutation Costs).

What this makes the browser do

And which of it is avoidable.

  • Style, layout and paint over roughly a viewport's worth of nodes rather than the whole dataset — the entire point.
  • Recomputing the window on scroll, which must stay cheap or it becomes the new bottleneck (Scroll and Input Latency).
  • Measuring rows when heights are dynamic, which is a layout read and must be batched away from writes.
  • Holding the full dataset in memory — virtualization bounds DOM cost, not data cost.

The trade, stated honestly

The performance case is overwhelming and is usually the only half that gets presented. The other half is that virtualization removes content from the DOM, and a surprising number of things users rely on are implemented against the DOM.

Adopting it is still frequently correct. Adopting it without mitigating the second column is how a table becomes fast and unusable for a subset of users.

PropertyAll rows renderedVirtualizedWhat to do about it
DOM nodes~1,000,000~30 rowsThe reason to do it
Initial renderOne very long blocking taskA viewport's worthThe reason to do it
Scroll costHigh and rising with sizeRoughly constantKeep the window computation cheap
MemoryGrows with the listBounded DOM; data still heldVirtualization does not bound the dataset
Find-in-pageWorksOnly the windowProvide in-app search over the data
Select-all and copyWorksOnly the windowOffer an explicit export
Screen-reader browse modeReaches every rowOnly the windowaria-rowcount / aria-rowindex, and keyboard nav over data
Deep link to a rowWorksRow may not exist yetScroll to index on load, then focus
PrintWhole listThe windowA separate print or export path
           data (100,000 rows, in memory)
  ┌──────────────────────────────────────────┐
  │  0                                       │
  │  …                                       │
  │ 4,180  ┐                                 │
  │ 4,181  │  overscan  (rendered, unseen)   │
  │ 4,182  ┘                                 │
  │ 4,183  ┐                                 │
  │  …     │  viewport  (rendered, visible)  │  ~30 rows in the DOM
  │ 4,212  ┘                                 │
  │ 4,213  ┐  overscan  (rendered, unseen)   │
  │ 4,215  ┘                                 │
  │  …                                       │
  │ 99,999                                   │
  └──────────────────────────────────────────┘
     spacer height = 100,000 × rowHeight
     └─ keeps the scrollbar honest, so the browser
        still believes the list is its real length

Making it operable

The accessibility work is not optional garnish; it is what makes a virtualized grid a grid rather than a picture of one. Two mechanisms carry most of it: telling assistive technology the true size and position of what it can see, and making keyboard navigation traverse the data rather than the rendered window.

accessibility specVirtualized data gridA window over a much larger list

semantics A role="grid" (or a real <table>) carrying aria-rowcount set to the full row count — not the rendered count. Each rendered row carries aria-rowindex with its true 1-based position in the whole dataset, so position is announced correctly despite only a window existing.

Arrow Down / UpMoves one row through the data. At the window edge, extends the window and moves focus into the newly rendered row.
Page Down / UpMoves a viewport of rows, rendering as needed.
Home / EndJumps to the first or last row of the dataset, not of the window.
TabEnters and leaves the grid as a single stop; it does not tab through every row.
Focus
  • Focus follows the data index, never the DOM position — the window is rebuilt around the focused row.
  • A recycled node must never keep focus while its content changes; move focus deliberately or re-render the focused row identically.
  • Scrolling with the mouse must not steal focus from where the keyboard user left it.
  • Deep-linking to a row scrolls it into the window and then focuses it, in that order.
Announces
  • The full row count, from aria-rowcount, so the user knows the real size of the list.
  • The true row position on focus, from aria-rowindex — "row 4,183 of 100,000".
  • Loading state when a window is being fetched rather than merely rendered.

usually broken by Setting aria-rowcount to the number of rendered rows, so a screen reader announces "row 3 of 30" for a hundred-thousand-row table — and arrow keys that stop dead at the edge of the window, making everything past it unreachable.

The list is large. What should you actually do?

How large, how variable, and does the user need all of it?

Nothing — render it

when Up to a few hundred rows on realistic devices.

cost None. Measure first; the machinery is not free and the platform behaviours stay intact (Measure Before Optimising).

Paginate

when The user works in pages, or the data is served in pages anyway.

cost A navigation per page. Keeps find-in-page, select-all, print and simple semantics — frequently the better answer (Pagination From the Interface Backwards).

`content-visibility: auto`

when A long document or feed where DOM size is tolerable but rendering cost is not.

cost Skips off-screen rendering work without bounding DOM or memory; scroll height needs contain-intrinsic-size to stay stable (content-visibility).

Virtualize, fixed heights

when Thousands or more rows, uniform height.

cost Bounded DOM and the platform behaviours in the table above, each needing explicit mitigation.

Virtualize, variable heights

when Large list, rows genuinely differ in height.

cost All of the above plus measurement, estimation error and scroll-jump risk — roughly double the complexity (Layout Thrashing).

Change the question

when Nobody actually reads a hundred thousand rows.

cost Product work — search, filters, a better default view. Often the cheapest and best fix, and the one nobody proposes.

How to build it

Most important first.

  • Reach for it when the list is genuinely large and unbounded. For a few hundred rows the plain version is simpler and fast enough; measure before adopting (Measure Before Optimising).
  • Prefer fixed or predictable row heights. Variable heights are supportable and roughly double the complexity.
  • Use stable keys tied to data identity so reused nodes do not carry state from the row they previously displayed (Node Identity Across Updates).
  • Consider whether the user needs the whole list at all. Pagination, search or a better default filter often solve the real problem and cost nothing (Pagination From the Interface Backwards).
  • Check content-visibility first for long documents — it lets the browser skip rendering work for off-screen content with far less machinery, though it does not bound DOM or memory (content-visibility).
  • Handle the accessibility consequences deliberately, because they are the real cost and they do not fix themselves.
  • Keep the window computation off the critical path of the scroll handler, and never write layout inside it after reading (Layout Thrashing).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • This is the honest downside and it must be stated plainly: a screen reader can only perceive what is in the accessibility tree, and virtualization removes most of the list from it. Browse-mode navigation, which is how many screen-reader users read a table, only reaches the rendered window (The Accessibility Tree).
  • Find-in-page has the same limitation for everyone, and it is a primary navigation tool for users with cognitive and visual disabilities.
  • Mitigate deliberately: set aria-rowcount on the grid and aria-rowindex on each row so assistive technology is told the real size and the real position, even though only a window is present.
  • Keyboard navigation must move through the *data*, not the DOM. Arrow keys at the edge of the window have to extend the window and move focus into the newly rendered row, or the list is a cage (Keyboard Operability).
  • Never let focus land on a recycled node whose content has changed underneath it — that silently moves the user to a different record (Focus Management).
  • Provide a non-virtualized path where the stakes justify it: a print view, an export, or a "show all" for smaller filtered results. Virtualization is a rendering strategy, and it should not be the only way to reach the data.

What can go wrong

Failure modes
  • Blank rows during fast scrolling, because the window is recomputed too slowly or the overscan is too small.
  • Scroll position jumping when measured heights differ from estimates, so the content moves under the user (Visual Stability).
  • Recycled rows carrying stale state — an expanded row, a focused input, a checked box appearing on the wrong record.
  • Find-in-page finding nothing, because the text is not in the DOM.
  • Ctrl+A / select-all copying only the visible window, silently.
  • Anchor links and deep links to a row failing, since the target may not exist yet.
  • A scroll handler doing layout reads and writes together, making scrolling the bottleneck it was meant to fix.
What can arrive out of order
  • A scroll event can arrive while the previous window computation is still in flight; applying them out of order renders the wrong slice.
  • Data updating while the window is computed can shift indices, so a row can be replaced by a different record under the user's pointer or focus.
Security
  • No direct surface, with one exception worth stating: because only a window is rendered, a client-side "hide these rows" filter is even less of a control than usual. The full dataset is in memory regardless of what is displayed (Authorization-Aware UI).
  • Filtering for authorization belongs on the server; the client is choosing what to paint (What the Frontend Is Responsible For in Auth).
Misreads
  • "Virtualize every long list." Below a few hundred rows the machinery usually costs more than it saves, and it costs accessibility unconditionally.
  • "It makes the data smaller." It bounds the DOM. The whole dataset is still in memory and still crossed the network (The Real Cost of JavaScript).
  • "Accessibility is handled by the library." Libraries provide the hooks; whether keyboard navigation reaches row 40,000 and whether the row count is announced are your decisions.
  • "Only rendering matters." Find-in-page, select-all, printing and deep links are platform behaviours users rely on, and windowing breaks all of them.
  • "content-visibility is the same thing." It skips rendering work for off-screen content while leaving the nodes in the DOM — cheaper to adopt, and it does not bound DOM or memory (content-visibility).

Measuring it, and what changes in the field

How you would see this
  • DOM node count before and after — the headline number, and usually a reduction of several orders of magnitude (What a Mutation Costs).
  • Main-thread time for the initial render, which is where the freeze lives (Long Tasks).
  • Dropped frames while scrolling, which is what the user actually feels (The Frame Budget).
  • Heap size across a long session, to confirm nodes are being reused rather than accumulated (Memory Leaks).
  • A keyboard-only and screen-reader pass, which is the only way to measure what virtualization cost.
Slow device, slow network, large data, old tab
  • On a slow device the plain version fails much sooner, so the threshold for adopting this is lower than desktop testing suggests.
  • With variable row heights the complexity rises sharply and the scroll-jump failure mode becomes the dominant risk.
  • With a filtered result set that is usually small, virtualization may be machinery for a case that rarely occurs — measure the realistic distribution, not the worst case.
What this costs
  • Bounded DOM and memory, in exchange for find-in-page, select-all, native anchor behaviour and simple accessibility semantics.
  • Fixed heights are far simpler and constrain design; variable heights preserve the design and add measurement and instability risk.
  • The alternative — pagination — costs a navigation and keeps every one of those platform behaviours intact. It is frequently the better answer and is dismissed too quickly (Pagination From the Interface Backwards).

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThat rendering cost scales with node count, and that windowing bounds it, follows from how the browser computes style and layout, so it holds in every engine and framework.
  • FRAMEWORK-SPECIFICNode reuse behaviour differs by reactivity model: a keyed diffing renderer reuses nodes and can carry stale state between rows unless identity is tied to the data, while fine-grained renderers update bindings in place — the failure mode differs but stable keys are required either way (Reactivity Models).
  • PLATFORM-SPECIFICScreen readers differ in how they interpret aria-rowcount and aria-rowindex on a partially-rendered grid, and browse-mode behaviour varies between NVDA, JAWS and VoiceOver — so a virtualized grid must be tested with more than one, not reasoned about from the specification.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Software Design — windowing is a projection over a collection, and keeping the projection separate from the collection is what keeps sorting, filtering and selection operating on data rather than on nodes.
  • Testing & Reliability Engineering — a keyboard-navigation test that reaches a row far outside the initial window is the check that catches the cage failure before a user does.