# What You’ll Build (and Why Virtualization Works)#
React list performance often collapses for one simple reason: the DOM gets too big. Rendering thousands of nodes increases layout, style recalculation, memory, and React reconciliation work. Virtualization fixes that by only rendering what is visible in the scroll viewport plus a small buffer.
In practice, virtualization commonly cuts DOM nodes by 90 to 99 percent. For example, a list of 10,000 rows rendered normally can easily create 10,000 to 50,000 DOM nodes depending on row complexity. A virtualized list might keep only 30 to 120 rows mounted at any time, which is a reduction from 10,000 down to around 60 rendered rows for typical viewports.
This guide shows how to implement:
- High-performance list and grid virtualization with TanStack Virtual
- Infinite loading that does not stutter during fast scroll
- Sticky headers that remain aligned while content is windowed
- Profiling workflows to prove measurable gains
- A production checklist for edge cases: dynamic heights, resizing, and accessibility
For deeper React performance patterns that complement virtualization, see React performance profiling, memoization, rendering patterns and for broader site-level performance considerations, read website performance optimization.
# When Virtualization Helps (and When It Hurts)#
Virtualization is most valuable when the bottleneck is DOM and render cost. It is less valuable when your bottleneck is network, expensive images, or heavy CPU work per item that still happens for every visible row.
Use virtualization when#
| Scenario | Typical symptoms | Expected impact |
|---|---|---|
| 1,000+ rows list or log viewer | Scroll jank, input lag, long commits in React Profiler | Major DOM reduction, smoother scroll |
| Data table with many rows and columns | Slow initial render, heavy memory usage | Big improvement, especially with column virtualization |
| Grid of cards (thumbnails, products) | CPU spikes during scroll | Reduced layout and repaint work |
| Infinite feed with paging | Stutters when appending new pages | Stable scrolling with buffered overscan |
Avoid or delay virtualization when#
| Scenario | Why it can be a bad fit | Better approach |
|---|---|---|
| Less than ~200 simple items | Complexity exceeds gains | Keep it simple, optimize renders |
| Rows need native browser search and selection across entire page | Only visible rows are in DOM | Provide dedicated search UI or server-side search |
| Complex focus management with many interactive controls per row | Unmounted elements lose focus state | Consider pagination or redesign row interactions |
| SEO requires all content in DOM | Virtualization hides content | SSR a summary, paginate, or render fewer items |
🎯 Key Takeaway: Virtualization is a UI performance tool, not a data fetching tool. Use it to reduce DOM and render work, then pair it with paging or infinite loading for data scale.
# Prerequisites and Setup#
| Requirement | Version | Notes |
|---|---|---|
| React | 18+ | Works with concurrent rendering |
| TanStack Virtual | 3+ | @tanstack/react-virtual |
| Browser APIs | ResizeObserver recommended | Helps dynamic measurement |
Install:
npm i @tanstack/react-virtualYou will implement a “scroll element” container, then let the virtualizer calculate which items should be rendered and where.
# Step 1: Virtualize a Fixed-Height List (fastest baseline)#
Start with fixed heights whenever possible. Fixed height gives the most stable performance because no runtime measurement is needed.
Minimal fixed-size list example#
import React, { useMemo, useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
export function VirtualizedList() {
const parentRef = useRef<HTMLDivElement | null>(null)
const items = useMemo(
() => Array.from({ length: 10000 }, (_, i) => `Row ${i}`),
[]
)
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40,
overscan: 8,
})
return (
<div
ref={parentRef}
style={{ height: 500, overflow: 'auto', border: '1px solid #ddd' }}
>
<div
style={{
height: rowVirtualizer.getTotalSize(),
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: virtualRow.size,
transform: `translateY(${virtualRow.start}px)`,
display: 'flex',
alignItems: 'center',
padding: '0 12px',
boxSizing: 'border-box',
borderBottom: '1px solid #f0f0f0',
}}
>
{items[virtualRow.index]}
</div>
))}
</div>
</div>
)
}Why this matters:
getTotalSize()creates the correct scroll height without rendering all rows.- Absolute positioning prevents the browser from laying out thousands of siblings.
overscanavoids blank gaps during fast scroll by rendering a buffer.
Tuning overscan with real numbers#
Overscan is a tradeoff: larger overscan increases CPU and memory but reduces the chance of blanking during fast scroll. On modern devices, 8 to 20 is a good starting range for lists.
| Content type | Recommended overscan | Rationale |
|---|---|---|
| Simple text rows | 6 to 12 | Fast to render, low blanking risk |
| Complex rows (avatars, menus) | 12 to 24 | Avoid visible mount pop-in |
| Heavy media (images, charts) | 4 to 10 | Too much overscan can spike memory |
💡 Tip: If users “fling scroll” on trackpads, increase overscan before you start rewriting row components. It is often the cheapest fix.
# Step 2: Virtualize a Grid (cards, galleries, dashboards)#
Grids need a consistent positioning strategy. The easiest approach is to virtualize rows, where each “row” contains multiple columns. That keeps the virtualizer one-dimensional and usually performs well.
Grid virtualization by row batching#
import React, { useMemo, useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
export function VirtualizedGrid() {
const parentRef = useRef<HTMLDivElement | null>(null)
const colCount = 4
const cardHeight = 180
const cards = useMemo(
() => Array.from({ length: 5000 }, (_, i) => ({ id: i, title: `Card ${i}` })),
[]
)
const rowCount = Math.ceil(cards.length / colCount)
const rowVirtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => parentRef.current,
estimateSize: () => cardHeight,
overscan: 6,
})
return (
<div ref={parentRef} style={{ height: 700, overflow: 'auto' }}>
<div style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
{rowVirtualizer.getVirtualItems().map((vRow) => {
const startIndex = vRow.index * colCount
const rowItems = cards.slice(startIndex, startIndex + colCount)
return (
<div
key={vRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: vRow.size,
transform: `translateY(${vRow.start}px)`,
display: 'grid',
gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))`,
gap: 12,
padding: 12,
boxSizing: 'border-box',
}}
>
{rowItems.map((c) => (
<div
key={c.id}
style={{
height: cardHeight - 24,
border: '1px solid #e5e5e5',
borderRadius: 10,
padding: 12,
boxSizing: 'border-box',
background: 'white',
}}
>
<strong>{c.title}</strong>
</div>
))}
</div>
)
})}
</div>
</div>
)
}If you need true two-dimensional virtualization (many rows and many columns), TanStack Virtual can do it with two virtualizers, one for rows and one for columns. That is especially relevant for spreadsheets and wide tables, and it pairs well with TanStack Table. For that full scenario, see React table virtualization and infinite scroll with TanStack Table.
# Step 3: Add Infinite Loading Without Scroll Glitches#
A virtualized list can still stutter when you append new pages if you block the main thread or trigger large rerenders. The goal is to fetch earlier, append efficiently, and keep stable item keys.
Pattern: sentinel row plus prefetch threshold#
- Reserve one extra “loading” row at the end.
- When the user scrolls close to the end, fetch the next page.
- Keep rendering stable with
overscanand a prefetch threshold.
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
type Row = { id: string; label: string }
export function VirtualizedInfiniteList() {
const parentRef = useRef<HTMLDivElement | null>(null)
const [rows, setRows] = useState<Row[]>(() =>
Array.from({ length: 200 }, (_, i) => ({ id: String(i), label: `Row ${i}` }))
)
const [isLoading, setIsLoading] = useState(false)
const hasMore = rows.length < 5000
const count = hasMore ? rows.length + 1 : rows.length
const v = useVirtualizer({
count,
getScrollElement: () => parentRef.current,
estimateSize: () => 44,
overscan: 12,
})
const virtualItems = v.getVirtualItems()
const lastItem = virtualItems[virtualItems.length - 1]
useEffect(() => {
if (!lastItem) return
const isAtEnd = lastItem.index >= rows.length - 1
if (!isAtEnd || isLoading || !hasMore) return
setIsLoading(true)
setTimeout(() => {
setRows((prev) => {
const next = prev.length
const more = Array.from({ length: 200 }, (_, i) => ({
id: String(next + i),
label: `Row ${next + i}`,
}))
return prev.concat(more)
})
setIsLoading(false)
}, 400)
}, [lastItem, rows.length, isLoading, hasMore])
const totalSize = useMemo(() => v.getTotalSize(), [v, rows.length, hasMore])
return (
<div ref={parentRef} style={{ height: 520, overflow: 'auto', border: '1px solid #ddd' }}>
<div style={{ height: totalSize, position: 'relative' }}>
{virtualItems.map((item) => {
const isLoader = hasMore && item.index === rows.length
const label = isLoader ? (isLoading ? 'Loading…' : 'Load more…') : rows[item.index].label
return (
<div
key={isLoader ? 'loader' : rows[item.index].id}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: item.size,
transform: `translateY(${item.start}px)`,
padding: '0 12px',
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
borderBottom: '1px solid #f0f0f0',
color: isLoader ? '#666' : '#111',
}}
>
{label}
</div>
)
})}
</div>
</div>
)
}What to measure:
- Time to append a page should stay roughly constant as total rows grow.
- Scrolling should not show blank gaps when data arrives.
- React commits should remain small during typical scroll interactions.
⚠️ Warning: Do not use array index as the React key for real data. When you insert, filter, or refresh pages, index keys cause remounts and state loss, which is especially visible in virtualized UIs.
# Step 4: Sticky Headers That Stay Aligned#
Sticky headers are common in tables, grids, and grouped lists. With virtualization, you typically want:
- A header that is not virtualized (always in DOM)
- A scroll container where only the body is virtualized
- Optional: sticky group headers inside the list
Sticky table header pattern#
Use a wrapper with a fixed header and a scrollable body. The header does not move, the body is virtualized.
| Layer | Positioning | Virtualized |
|---|---|---|
| Header row | sticky or separate container | No |
| Scroll container | overflow auto | N/A |
| Rows | absolute positioned inside spacer | Yes |
Implementation approach:
- 1Render header outside the virtualized body.
- 2Ensure header column widths match body columns.
- 3If using CSS grid or fixed widths, keep them in a single shared config object.
If you need a full table solution with sorting, pinning, and virtualization, start from React table virtualization and infinite scroll with TanStack Table.
ℹ️ Note: Sticky elements inside a transformed parent can behave unexpectedly. If you use
transformon ancestor elements of a sticky header, the browser may treat the sticky container differently. Keep sticky headers outside transformed containers, or use a separate header wrapper.
# Step 5: Dynamic Row Heights (the hard part)#
Real lists rarely have perfect fixed heights. Comments expand, badges wrap, error states appear, and fonts load late. TanStack Virtual supports measurement, but you must treat it as a system that needs stable constraints.
Best practices for dynamic heights#
| Problem | Symptom | Fix |
|---|---|---|
| Height changes after render | Overlaps, jumpy scroll | Measure element, re-measure on content change |
| Responsive layout | Wrong estimates after resize | Call virtualizer.measure() on resize |
| Late-loaded fonts or images | Layout shift while scrolling | Set explicit image sizes, reserve space, re-measure after load |
| Mixed heights | Poor scroll accuracy | Use reasonable estimateSize and measurement |
Practical approach:
- 1Keep a good
estimateSize(close to median row height). - 2Measure actual row elements.
- 3Avoid expensive layout inside each row.
If your row content changes after an async update, re-measure. Do it intentionally, not on every render.
import React, { useEffect, useRef, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
export function VirtualizedDynamicRows() {
const parentRef = useRef<HTMLDivElement | null>(null)
const [rows] = useState(() =>
Array.from({ length: 3000 }, (_, i) => ({
id: String(i),
text: i % 7 === 0 ? 'A longer row that will likely wrap on smaller widths.' : 'Short row.',
}))
)
const v = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 52,
overscan: 10,
})
useEffect(() => {
const onResize = () => v.measure()
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [v])
return (
<div ref={parentRef} style={{ height: 560, overflow: 'auto', border: '1px solid #ddd' }}>
<div style={{ height: v.getTotalSize(), position: 'relative' }}>
{v.getVirtualItems().map((item) => (
<div
key={rows[item.index].id}
ref={v.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${item.start}px)`,
padding: 12,
boxSizing: 'border-box',
borderBottom: '1px solid #f0f0f0',
lineHeight: 1.3,
}}
>
<strong>#{rows[item.index].id}</strong> — {rows[item.index].text}
</div>
))}
</div>
</div>
)
}This pattern trades some CPU for correctness. The fewer dynamic changes you have, the better it will feel.
# Profiling: Prove the Performance Gain (not just “feels faster”)#
Virtualization should be measurable. Your goal is to reduce:
- Mounted DOM nodes
- React commit duration during scroll
- Long tasks on the main thread
What to capture before and after#
| Metric | Tool | Target |
|---|---|---|
| DOM node count | Chrome DevTools, Elements panel | Reduce by 90 percent or more for big lists |
| React commit time | React DevTools Profiler | Smaller commits during scroll, fewer rerenders |
| Long tasks | Chrome Performance panel | Fewer tasks over 50 ms during scroll |
| Memory | Chrome Performance or Task Manager | Lower heap usage, fewer detached nodes |
Recommended workflow:
- 1Record a baseline with a non-virtualized list of the same row component.
- 2Record the virtualized version with the same dataset size.
- 3Scroll at a consistent speed and compare React commits and long tasks.
For React-specific profiling patterns and how to interpret commit graphs, use React performance profiling, memoization, rendering patterns.
Practical profiling tips#
- Profile on a throttled CPU mode (for example 4x slowdown) to surface issues that real users on mid-range devices hit.
- Measure “scroll while new page loads” for infinite lists, not just idle scroll.
- Watch for avoidable rerenders: row components should not rerender when unrelated state updates.
If you see commits triggered by scrolling, check that you are not storing scroll position in React state. Let the browser handle scroll, and let the virtualizer derive what is visible.
# Accessibility and UX Checklist (virtualization-specific)#
Virtualization changes the DOM while the user scrolls. That affects keyboard navigation, screen readers, and expectations like “find in page.”
Keyboard and focus#
- 1Ensure focus does not land on items that will unmount immediately.
- 2If rows contain inputs, preserve state outside the row component (store by id).
- 3Provide clear focus styles and ensure tab order remains predictable.
Screen readers and semantics#
- Prefer semantic containers where possible, but do not force
tablesemantics if you cannot keep correct relationships. - Provide
aria-labelon the scroll region so it is discoverable. - Announce loading states for infinite lists with an aria-live region if loading is automatic.
“Find in page” and selection#
Browser find only sees mounted DOM. For data-heavy apps, provide an explicit search UI and filter the dataset, rather than relying on native find.
⚠️ Warning: Virtualized tables that pretend to be real HTML tables often break assistive tech expectations. If you need true table semantics for accessibility compliance, consider pagination or a hybrid approach where you limit rows but keep a real table structure.
# Edge Cases Checklist (production readiness)#
Use this as a pre-launch checklist for any “React virtualization TanStack Virtual” implementation.
| Edge case | Risk | Mitigation |
|---|---|---|
| Dynamic row heights | Overlaps and scroll jumps | Use measureElement, good estimateSize, re-measure on resize |
| Images loading late | Layout shift | Set width and height, reserve space, re-measure after load if needed |
| Filtering and sorting | Jump to wrong scroll offset | Reset scroll to top, keep stable keys, avoid index keys |
| Appending pages | Janky scroll during fetch | Prefetch earlier, keep overscan, render loader row |
| Variable container size | Incorrect measurements | Call measure() on resize and layout changes |
| Sticky header alignment | Misaligned columns | Shared column width config, header outside transformed container |
| Server rendering | Hydration mismatch | Render a limited initial window, avoid reading scroll position on first paint |
| Touch devices | Blank gaps during fast fling | Increase overscan and prefetch threshold |
If you are optimizing the entire page rather than just the list, coordinate virtualization with broader improvements like image optimization, caching, and bundle size reduction. Use website performance optimization to avoid fixing one hotspot while ignoring bigger bottlenecks.
# Key Takeaways#
- Start with fixed-size virtualization first, because it is the fastest and simplest baseline to ship.
- Tune
overscanbased on row complexity and input devices, then validate with React Profiler and Chrome Performance. - Implement infinite loading using a loader row and a prefetch threshold so scroll never hits an empty gap.
- Treat dynamic row heights as a first-class edge case: measure elements, re-measure on resize, and reserve space for late-loading content.
- Plan accessibility explicitly: manage focus, provide search UI instead of relying on browser find, and avoid fake table semantics if you need compliance.
# Conclusion#
TanStack Virtual is one of the most practical ways to make large React lists and grids feel instant: fewer DOM nodes, smaller React commits, and smoother scroll under real-world loads. The real win comes when you combine windowing, infinite loading, and disciplined profiling so you can prove the improvement instead of guessing.
If you want us to review your current list or table implementation, profile it, and ship a production-ready virtualization setup with sticky headers and infinite loading, contact Samioda via our React and Next.js services page and include a screen recording plus your current dataset size and row complexity.
FAQ
Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.
More in Web Development
All →Next.js Supabase Realtime in 2026: End-to-End Blueprint for Chat, Presence, and Collaboration
Build production-ready realtime UI with Next.js App Router and Supabase Realtime: schema design, RLS, optimistic updates, presence, scaling, and troubleshooting duplicate events and permission mismatches.
Next.js Authorization in the App Router: RBAC vs ABAC with Middleware, RLS, and Policy Patterns
A practical guide to Next.js authorization in the App Router using RBAC and ABAC — with middleware checks, server component guards, database-enforced RLS, decision matrix, and copy-paste policy patterns.
React Testing Strategy in 2026: Vitest + React Testing Library + MSW for Confident Releases
A pragmatic React testing strategy for 2026 using Vitest, React Testing Library, and MSW. Learn a realistic test pyramid, reduce flakiness, and ship confidently with CI-ready patterns.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
React Table Virtualization & Infinite Scroll: Building Fast Data Grids with TanStack (2026 Guide)
Learn React table virtualization with TanStack Table, TanStack Virtual, and React Query: efficient rendering, infinite scroll, server-side sorting/filtering, URL sync, selection persistence, and optimistic updates.
Next.js App Router UX Patterns: Error Boundaries, Loading UI, and Streaming Done Right
A practical guide to resilient UX in Next.js App Router: route segment structure, error.tsx, loading.tsx, not-found.tsx, and Suspense streaming patterns for partial rendering, safer data fetching, and fewer layout shifts.
The React Code Review Checklist We Use: Performance, Accessibility, and Maintainability
A practical React code review checklist focused on performance, accessibility, and maintainability, with examples, automation tips, and a copy-paste template.