React Virtualization: Optimizing Large List Performance
Rendering 10,000 records freezes the page? A virtual list renders only the rows in view and swaps them dynamically as you scroll. This article explains window calculation and DOM reuse, how to use react-window and TanStack Virtual, when virtualization makes sense, and its tradeoffs for search, accessibility, and dynamic heights.
Key Points
- Topic Focus: React、TypeScript
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
React Virtualization: Optimizing Large List Performance
A product team asks you to display 10,000 order records at once on an admin page. You write <tbody>{orders.map(o => <Row />)}</tbody>, open DevTools, and see that the initial render takes three seconds, scrolling drops to 12 fps, and memory usage in Chrome Task Manager shoots straight to 800 MB. You wonder: it is just a list, so why is it this slow?
The answer is simple: DOM nodes are the browser's most expensive resource, bar none. Virtualization is the standard solution to this kind of problem. It lets 10,000 records render as only the 20 rows you can actually see.
1. Why DOM Nodes Are the Browser's Most Expensive Resource
Many people's intuition about React performance stops at "component rendering is slow," so they optimize useMemo, React.memo, and useCallback. Once the dataset grows to several thousand records, however, the bottleneck is no longer React. It is the browser:
- Rendering overhead: Every DOM node must go through the full parseHTML, attach style, layout, and paint pipeline. The cost of 10,000 nodes does not grow linearly; performance degradation approaches exponential.
- Style recalculation and layout: Scrolling, window resizing, or any reflow can trigger layout recalculation across a large number of nodes. The more nodes there are, the slower each reflow becomes.
- Memory usage: An ordinary DOM node consumes anywhere from a few KB to tens of KB of memory once styles, events, and internal data structures are included. Ten thousand complex rows can easily consume hundreds of MB.
- Event dispatch and hit-testing: The browser performs hit-testing on every mouse movement and scroll event. More nodes make this work slower.
React's reconciliation happens at the JavaScript layer, and tens of thousands of vnode diffs are actually a minor problem for modern engines. The real cost arrives after React turns those vnodes into actual DOM and the browser must maintain all of it. Once the dataset reaches a certain scale, the "reduce React renders" optimization path has run its course. What you really need to reduce is the absolute number of DOM nodes.
2. How Virtualization Works: Window Calculation and DOM Reuse
The core idea behind virtualization can be expressed in one sentence: Render only the rows visible inside the viewport and dynamically replace their content as the user scrolls.
Suppose the container is 600 px tall, each row is 40 px tall, and the viewport can hold 15 rows. A virtual list does three things:
- Calculate the window: It uses the current
scrollTopto determine which rows belong in the viewport. For example,startIndex = Math.floor(scrollTop / 40)andendIndex = startIndex + 15. - Use a large container to create the scrollbar: It places a placeholder element with a height of
10000 * 40 = 400000pxin the outer container, making the browser's native scrollbar look correct. Scrolling then behaves exactly like it does in an ordinary list. - Position visible rows correctly with
transformorabsolutepositioning: The DOM nodes do not actually "scroll." Instead, the window is recalculated, new rows are moved into position with a transform, and rows that leave the window are unmounted immediately.
Throughout this process, the number of DOM nodes stays constant at about 20: 15 visible rows plus a few buffered rows above and below. It does not matter whether the underlying dataset contains 10,000 or one million records. The browser always sees a small, 20-row list.
That is also why a virtual list can remain smooth with one million records: its complexity is O(number of visible rows), independent of the total dataset size.
3. react-window vs. TanStack Virtual: Which Should You Choose?
The ecosystem has two mainstream options, and both are worth understanding.
react-window: A classic library written by Brian Vaughn, one of the people outside the Vue/Radix camp who understands lists best. It is the spiritual successor to react-virtualized, lightweight at about 6 KB, simple to use, and stable. It is well suited to fixed-height rows.
import { FixedSizeList as List } from 'react-window';
function Row({ index, style }: { index: number; style: React.CSSProperties }) {
return <div style={style}>Row {index}</div>;
}
function BigList() {
return (
<List height={600} itemCount={10000} itemSize={40} width={600}>
{Row}
</List>
);
}
The API is extremely minimal: itemSize is the fixed row height, the library calculates the window, and you only render a single row. There are no unnecessary concepts, making it suitable for 90% of fixed-height use cases.
TanStack Virtual (@tanstack/react-virtual): Tanner Linsley's next-generation solution. It is more flexible and does not bind you to React's children render prop. Instead, it gives you a virtualizer instance while you control the container and positioning. Most importantly, it natively supports dynamic heights through measureElement.
import { useVirtualizer } from '@tanstack/react-virtual';
function BigList({ rows }: { rows: string[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40, // Estimated row height
overscan: 5, // Render 5 extra rows above and below as a buffer
});
return (
<div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
<div
style={{
height: virtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((vi) => (
<div
key={vi.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vi.start}px)`,
}}
>
Row {vi.index}: {rows[vi.index]}
</div>
))}
</div>
</div>
);
}
Recommendation: For fixed row heights and simple requirements, use react-window. For dynamic heights, two-dimensional horizontal and vertical virtualization, tables, or integration with TanStack Table, use TanStack Virtual. For a new project, I would choose TanStack Virtual directly. Its mental model is more modern, and it integrates seamlessly with the TanStack ecosystem, including Table and Query. react-window has entered maintenance mode, so it is unlikely to gain substantial new functionality.
4. When You Should and Should Not Virtualize
This is the most important decision in this article. Do not reach for virtualization every time you see a long list.
You should virtualize when:
- The dataset has more than 500 rows, and row height is fixed or can be calculated, such as
itemSize = 40. - You have an infinite-scrolling list where the user can keep scrolling and DOM nodes would otherwise continue accumulating.
- You have a large data table, such as a financial, order, or log administration interface where thousands or tens of thousands of rows are normal.
You should not virtualize when:
- The list has fewer than 500 rows. The browser can handle it, and the complexity introduced by virtualization, including testing, accessibility, and SSR, is not worth it. I have seen someone virtualize a 200-row settings list, which was complexity for complexity's sake.
- Row heights are completely unpredictable, such as rich-text comments whose content lengths vary enormously and cannot be estimated. Dynamic-height virtualization is possible, but it has many pitfalls, as discussed in Section 6.
- You need full-text search. The browser's Ctrl+F cannot find rows that have not been rendered, which is a fundamental limitation of virtualization.
- The content is important for SEO. During SSR, a virtual list renders only the first few dozen rows, so crawlers cannot access the complete content. On pages that depend on search traffic, such as blog archives and product listings, virtualization can be disastrous.
In short: Virtualization solves engineering problems that the browser genuinely cannot handle; it is not a technique for showing off. Start with pagination or infinite scrolling, and introduce virtualization only when those approaches no longer hold up.
5. The Costs of Virtualization: Accessibility, Ctrl+F, and SEO
Virtualization is not free. It sacrifices three things you may not have considered:
Accessibility: Screen readers can read only the rows that currently exist in the DOM. If a virtual list renders just 20 rows, a visually impaired user navigating with JAWS or NVDA can access only those 20; the remaining 9,980 records effectively do not exist. One mitigation is to add aria-rowcount and aria-rowindex to the container so the screen reader at least knows the total row count and the current row number, but this provides only partial relief.
Browser search (Ctrl+F): This is the issue users complain about most. If someone presses Ctrl+F on a list page and searches for "Zhang San," the browser can search only the 20 rows currently in view. It cannot find unrendered rows. This violates users' basic expectations of how a web page should behave. If users frequently search within your list, virtualization is almost a deal-breaker.
SEO: During server-side rendering, a virtual list outputs only the rows in the initial viewport, so crawlers cannot see the full dataset. React's renderToString does not simulate scrolling and therefore renders only the first screen. For pages that require SEO, such as product listings or article archives, either abandon virtualization in favor of SSR-friendly pagination or use a hybrid strategy that renders the first N records initially and enables virtualization on the client.
These costs are not bugs. They are inevitable consequences of how virtualization works. Browsers and crawlers naturally cannot perceive content that does not exist in the DOM. These tradeoffs must be included in your decision matrix.
6. Dynamic Heights: The Hardest Part of Virtualization
With fixed row heights, calculating the window is an O(1) division. In the real world, however, many rows have variable heights, including comments, rich text, and collapsible cards. Virtualization becomes much more complex because window calculation depends on each row's actual height, which is unknown until the row has been rendered.
TanStack Virtual solves this with estimation, measurement, and correction:
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 60, // Start with a conservative estimate
measureElement: (el) => el.getBoundingClientRect().height, // Measure the actual height
});
// During rendering
{virtualizer.getVirtualItems().map((vi) => (
<div
key={vi.key}
ref={(node) => node && virtualizer.measureElement(node)}
data-index={vi.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vi.start}px)`,
}}
>
{rows[vi.index]}
</div>
))}
The workflow is as follows: estimateSize initially estimates every row's height and creates the scrollbar. Once a row enters the viewport and is rendered, measureElement measures its actual height, updates the cache, and triggers a correction that recalculates positions. data-index is the key used to match measurements with cached rows.
This mechanism works, but it has three pitfalls:
- Scrollbar jumps: If the estimate is inaccurate, scrolling into the middle of the list may suddenly jump because the corrected total height has changed. The more accurate
estimateSizeis, the less jumping occurs. - Scrolling to a specific row is inaccurate: The position calculated by
scrollToIndex(5000)is an estimate and must be corrected again after the target comes into view. - Flickering during fast scrolling: A row is initially positioned using its estimated height and laid out again after measurement, which users can see. Increasing
overscancan mitigate this, but it also increases rendering cost.
For dynamic-height scenarios, I first try to use "fixed content plus grouping by type," so rows of the same type share one height and the problem becomes fixed-height virtualization again. Only when that is impossible do I use measureElement and accept some loss of user experience.
7. Alternatives: Pagination and Infinite Scrolling
Virtualization is not the only solution, and sometimes it is the wrong one entirely.
Pagination: The oldest and most reliable option. Render 50 records per page and only the current page when the user navigates. It is SSR-friendly, supports shareable URLs such as ?page=3, works well for search engines, and lets Ctrl+F search the whole page. The drawback is that users dislike changing pages, and the experience feels less natural than scrolling. Pagination remains the first choice for admin systems and filtered e-commerce pages.
Infinite scrolling without virtualization: Append new data when the user reaches the bottom, allowing the DOM to keep accumulating. This is the approach used by Instagram and Twitter. The difference from virtualization is simple: old rows are never unmounted, so the DOM keeps growing. The advantages are simplicity, the ability for Ctrl+F to search all loaded content, and better accessibility. The drawback is that after the user scrolls for a long time, perhaps through thousands of records, the accumulated DOM can slow down the page.
The decision criteria are clear:
- Users usually leave after viewing the first few dozen records → Infinite scrolling, which is simple and elegant.
- Users genuinely scroll through thousands of records → Virtualization.
- The data must support search, SEO, or shareable URLs → Pagination.
Summary
- DOM nodes are the browser's most expensive resource. The performance bottleneck in a large list is the browser, not React.
- Virtualization renders only the few dozen rows in the viewport, keeping the DOM count constant and decoupled from the total dataset size.
- react-window suits fixed row heights, while TanStack Virtual suits dynamic heights and complex scenarios. Prefer TanStack for new projects.
- Virtualization has costs: Screen readers cannot access every row, Ctrl+F cannot find unrendered content, and SEO suffers. These are fundamental limitations, not bugs.
- Start with pagination, then infinite scrolling, and virtualize only when necessary. Think especially carefully about dynamic heights because
measureElementhas plenty of pitfalls.
In one sentence: Virtualization is not a silver bullet. It trades engineering complexity for fewer DOM nodes. The trade is worthwhile only when the DOM has genuinely become the bottleneck and you are willing to accept the costs to accessibility and search.
Related
Related Content
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.
Article
SEO Design for a Content Community with Next.js
Technical trends, engineering practices, and an indie developer's perspective on Next.js and TypeScript.
Article
The Engineering Value of TypeScript in AI Application Frontends
Technical trends, engineering practices, and an independent developer perspective on TypeScript and React.
Article
What Value Do Server Components Offer Content Sites?
Technical trends and engineering practices around Next.js and React, from an independent developer's perspective.