Implementing Virtualized Infinite Scroll with React TanStack Virtual + useInfiniteQuery: Rendering 50,000 Items with Only 60 DOM Nodes
This article is written for frontend developers who have basic experience with TanStack Query and have encountered performance issues with large lists.
Anyone who has done frontend development long enough will eventually face a situation like this: a screen where data accumulates in the tens of thousands — product lists, feeds, log viewers — where scrolling gets increasingly sluggish, memory keeps climbing, and the tab eventually slows to a crawl or crashes entirely. I personally experienced a moment when a feed with 60,000 items pushed tab memory past 1.8GB, and after that I decided to take this problem seriously. I lost two days to scroll jump issues, and there was a time when omitting a single isFetchingNextPage condition caused an infinite loop that exploded server requests.
In this article, I'll share how to implement a virtualized infinite scroll pattern by combining useVirtualizer from @tanstack/react-virtual with useInfiniteQuery from @tanstack/react-query — keeping the number of nodes actually present in the DOM fixed at 60–80 at all times, regardless of whether there are 50,000 or 500,000 items. This isn't a simple introduction; I've honestly documented the scroll jump issues, infinite re-render problems, and memory leak situations I encountered through real hands-on experience.
Core Concepts
What Is Virtualization?
Simply put, it's the concept of "only rendering what's visible." Even if there are 50,000 items, only about 15–20 are actually visible in the current viewport. Virtualization mounts only that visible portion — plus a small buffer (overscan) for scroll prediction — into the real DOM, while using CSS transform: translateY() to reserve positions for the rest.
Also known as windowing, this pattern maintains the total list height at the actual calculated value while continuously swapping out the range of items to render based on scroll position. To the user it looks like the full list is there, but the DOM always contains only a fixed number of nodes.
Rendering all 50,000 items would create 50,000 DOM nodes; with virtualization, the count is fixed at around 40–80 including overscan. The layout recalculation cost and memory usage are in a completely different league.
Note that thinking of overscan as simply a "buffer" can cause problems down the line. Too small and you'll see rendering gaps (white flicker) when scrolling fast; too large and the unnecessary DOM rendering overhead actually degrades performance. It's generally best to tune it somewhere between 3 and 10, balancing viewport scroll speed against item rendering cost.
Separation of Responsibilities Between the Two Libraries
This is the most important part for understanding this pattern. I was also confused at first about whether the two libraries overlapped, but their roles are completely different.
| Library | Responsible for | Not responsible for |
|---|---|---|
useVirtualizer |
Scroll position calculation, determining the index range of items to render, providing translateY values |
Data fetching, network requests |
useInfiniteQuery |
Asynchronous page-by-page fetching, accumulating data.pages, providing next-page state |
Scroll calculation, DOM rendering control |
The data flow when the two libraries are integrated looks like this:
Scroll event
→ useVirtualizer detects the last virtual item index
→ If lastItem.index >= allRows.length - 1, call fetchNextPage()
→ useInfiniteQuery fetches the next page
→ flatMap data.pages to update allRows
→ Reflect count = allRows.length in useVirtualizer
→ New items are mounted in the DOM when they enter the virtualization rangePractical Application
First, install the two libraries.
pnpm add @tanstack/react-query @tanstack/react-virtualExample 1: Basic Infinite Scroll Virtualization Integration
This is the most fundamental pattern. It covers cases like product lists or post feeds where you continuously load data while scrolling from top to bottom.
💡 In the code below,
fetchItemsis assumed to be an async function that returns{ items: Item[], nextCursor: number | null }.
import { useInfiniteQuery, type InfiniteData } from '@tanstack/react-query'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef, useEffect } from 'react'
type Page = { items: Item[]; nextCursor: number | null }
function InfiniteList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery<Page, Error, InfiniteData<Page>, string[], number>({
queryKey: ['items'],
queryFn: ({ pageParam }) => fetchItems(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
maxPages: 5,
})
const allRows = data ? data.pages.flatMap((p) => p.items) : []
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: hasNextPage ? allRows.length + 1 : allRows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 64,
overscan: 5,
})
const virtualItems = virtualizer.getVirtualItems()
useEffect(() => {
const lastItem = virtualItems[virtualItems.length - 1]
if (!lastItem) return
if (
lastItem.index >= allRows.length - 1 &&
hasNextPage &&
!isFetchingNextPage // Without this condition, requests fire on every scroll
) {
fetchNextPage()
}
}, [virtualItems, allRows.length, hasNextPage, isFetchingNextPage, fetchNextPage])
return (
<div
ref={parentRef}
style={{ height: '600px', overflow: 'auto' }}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative',
}}
>
{virtualItems.map((virtualItem) => {
const isLoaderRow = virtualItem.index > allRows.length - 1
const row = allRows[virtualItem.index]
return (
<div
key={virtualItem.key}
ref={(node) => virtualizer.measureElement(node)}
data-index={virtualItem.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
{isLoaderRow ? (
hasNextPage ? <LoadingRow /> : null
) : (
<ItemComponent item={row} />
)}
</div>
)
})}
</div>
</div>
)
}Key points to note in the code:
| Point | Description |
|---|---|
count: hasNextPage ? allRows.length + 1 : ... |
Reserves a virtual item slot for the loading indicator when the next page is available |
ref={(node) => virtualizer.measureElement(node)} |
Must be connected as a callback to avoid misfires when React passes null during cleanup |
isFetchingNextPage check |
Prevents duplicate calls while fetching — without it, requests explode on every scroll |
Including fetchNextPage in deps |
TanStack Query's fetchNextPage has a stable reference so there's no infinite loop risk; including it is the correct approach |
| TypeScript generics | Using useInfiniteQuery<Page, Error, InfiniteData<Page>, ...> prevents type inference pollution |
Example 2: Handling Variable-Height Items
For cases where items have different heights — like feed posts or comments — using a fixed estimateSize will cause severe scroll jumping. This is where measureElement plays a critical role.
From Example 1, only the virtualizer configuration and the item ref portion need to be changed as follows:
// virtualizer configuration
const virtualizer = useVirtualizer({
count: allRows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100, // Initial estimate (measureElement corrects even if off)
overscan: 3,
})
// Rendering — ref connection is the same
<div
key={virtualItem.key}
ref={(node) => virtualizer.measureElement(node)}
data-index={virtualItem.index}
style={{ ... }}
>
<DynamicHeightItem item={allRows[virtualItem.index]} />
</div>The ref callback connected to measureElement doesn't just measure the actual height when the DOM node mounts — it also continuously monitors size changes via ResizeObserver internally. This means it automatically handles cases where the height changes after mounting, such as images loading late or accordions expanding. Not knowing this will have you debugging for a long time wondering "why does the height respond dynamically?"
Example 3: Controlling Memory with maxPages
Honestly, when I first saw this option I thought "it's just limiting pages, right?" — but I came to understand how important it is in real usage patterns involving long scroll sessions. Scrolling a feed for over 30 minutes can accumulate dozens of pages in memory, and without limiting this, the tab will eventually crash.
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) => fetchFeed(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
maxPages: 10, // With 20 items per page, keeps a max of 200 items in memory
})With maxPages: 10, when the 11th page is fetched, the oldest first page is automatically removed from memory. Scrolling back up will re-fetch that data. Because of this tradeoff, the pageSize × maxPages combination needs to be balanced against actual usage patterns and acceptable network costs.
Conditions vary, but in long-scroll scenarios you can typically see significant memory reduction — sometimes tens of percent — before and after applying maxPages.
Pros and Cons Analysis
Advantages
| Item | Details |
|---|---|
| Consistent DOM node count | Rendering DOM is fixed at ~60 nodes regardless of item count, reducing memory and layout recalculation costs |
| 60FPS scrolling | Provides smooth scrolling experience with large datasets without rendering bottlenecks |
| Headless design | Full control over markup and styles; freely combinable with any CSS library |
| Framework agnostic | Supports React/Vue/Solid/Svelte/Angular, bundle size 10–15kb |
| Memory limiting | maxPages option limits accumulated pages during long scroll sessions |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Scroll jumping | The larger the gap between estimateSize and actual height, the more the scroll position lurches |
Immediately correct with measureElement, estimate as close to the actual value as possible |
maxPages re-fetching |
Additional network requests occur when scrolling back to removed pages | Tune page size and maxPages values to match usage patterns |
| SSR limitations | DOM size-based behavior can cause hydration mismatches during server rendering | Isolate to client-only rendering; use suppressHydrationWarning |
| SEO disadvantage | Virtualized content is invisible to crawlers | Consider static rendering instead of virtualization for SEO-critical content |
| Accessibility (a11y) | Screen readers cannot recognize non-rendered items | Additional ARIA attributes like aria-rowcount, aria-rowindex required |
In practice, the SEO issue was ultimately handled by separating the feed page into a crawler-specific static version and a user-facing version. The a11y issue was resolved by manually attaching ARIA attributes to each item. Not elegant, but a realistic choice.
💡 Hydration Mismatch: A phenomenon where React throws a warning because the HTML rendered on the server differs from the result re-rendered on the client. Because virtualization operates based on viewport size, there is a mismatch between the server environment (no viewport) and the client environment.
The Most Common Mistakes in Practice
-
Omitting the
isFetchingNextPagecheck — If you leave out this flag from the condition for callingfetchNextPage()insideuseEffect, requests pile up with every scroll event and you fall into an infinite loop. You have to experience it once to truly appreciate the horror. You'll see the server request graph shoot straight up vertically. Always make sure to check the!isFetchingNextPagecondition alongside the others. -
Setting
estimateSizetoo inaccurately — If your actual item height is 64px but you giveestimateSizea value of 200px, the scroll position calculation will be completely off from the initial render. Measuring the actual item height in DevTools and providing the closest possible value is the most reliable way to reduce scroll jumping. -
Missing
position: relativeon the container — Virtual items have their position determined byposition: absolute+transform: translateY(). If the parent container lacksposition: relative, items will position themselves relative to the viewport and the layout will break completely. Always make sure to setposition: relativeon the<div>whose height is set viavirtualizer.getTotalSize().
Closing Thoughts
The combination of TanStack Virtual and useInfiniteQuery is not simply a performance optimization — it has become a de facto standard pattern in frontend production environments dealing with large volumes of data. Understand the separation of responsibilities between the two libraries, handle variable heights with measureElement, and control memory with maxPages — get those three things right and processing 50,000 items with 60 DOM nodes is entirely achievable.
Three steps you can start with right now:
- Add basic virtualization — After running
pnpm add @tanstack/react-query @tanstack/react-virtual, start by wrapping your existing list rendering withuseVirtualizer. Just settingcountandgetScrollElementcorrectly is enough to get basic virtualization working. - Verify DOM node count — Open the DevTools Elements panel and scroll while confirming that the actual DOM node count stays fixed around 60–80. If that number stays fixed, virtualization is working correctly.
- Reach production quality — If item heights are variable, add
ref={(node) => virtualizer.measureElement(node)}anddata-indexto each item, then set themaxPages: 5~10option to cap memory usage.
Next article: A large-scale table implementation pattern combining TanStack Table + TanStack Virtual + React Query to virtualize both rows and columns in a data grid with tens of thousands of rows
References
- TanStack Virtual — Infinite Scroll Example (React)
- TanStack Virtual — Virtualizer API
- TanStack Query — Infinite Queries
- GitHub — TanStack Virtual infinite-scroll/main.tsx
- LogRocket Blog — How to speed up long lists with TanStack Virtual
- DEV Community — Building an Efficient Virtualized Table with TanStack Virtual and React Query with ShadCN
- Hashnode — Infinite List with Tanstack: React Query & Virtual
- Makers Den — Infinite Scroll and Streaming Data with TanStack Query + React 19
- Medium — From Lag to Lightning: How TanStack Virtual Optimizes 1000s of Items Smoothly
- DeepWiki — TanStack Virtual Infinite Scrolling