Virtualization for Long Lists: Performance in JavaScript
Rendering 10,000 items? Virtualization reduces DOM nodes from thousands to ~50, transforming sluggish lists into butter-smooth experiences. Here's how.
While I was reviewing a product catalog implementation last week, I watched as my colleague's face went from excited to confused. "Why is scrolling so janky?" he asked, watching his browser grind through rendering 5,000 product cards. The app worked perfectly with 50 items during development, but in production with real data, it was unusable. Little did he know that virtualization could have prevented this entire mess, and I cannot stress this enough—understanding this technique will transform how you build data-heavy applications.
The Problem: Why Rendering Long Lists Kills Performance
When you render a list of thousands of items in React—or any JavaScript framework—you're asking the browser to create and manage thousands of DOM nodes simultaneously. Each DOM node consumes memory, requires layout calculations, and needs to be painted and composited on every scroll event. The performance impact compounds quickly.
Let me show you what this looks like in practice. Here's how most developers initially approach rendering a large list:
// ❌ PROBLEM: Rendering all 10,000 items at once
function ProductList({ products }) {
return (
<div style={{ height: '600px', overflow: 'auto' }}>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
)
}This code looks innocent enough. We map over our products array, render a ProductCard for each one, and wrap everything in a scrollable container. In other words, we're doing what we've been taught to do. But here's where the problems start:
Real performance metrics from this approach:
- Initial render time: 3-5 seconds for 10,000 items
- Memory usage: ~250MB just for the DOM alone
- Scroll performance: 15fps (janky and unresponsive)
- Time to Interactive (TTI): Over 6 seconds
- Lighthouse Performance Score: 12/100
The browser is choking. It's trying to maintain 10,000 DOM nodes, recalculate layouts on every scroll, and keep everything painted correctly. Your users experience frozen UIs, janky scrolling, and the dreaded "Page Unresponsive" dialog.
Make no mistake about it—this isn't a React problem or a JavaScript problem. It's a fundamental browser limitation. The DOM simply wasn't designed to handle thousands of nodes efficiently, and no amount of optimization can change that physics.

The mistake developers make is testing with small datasets. "It works with 50 items during development, ship it!" But when real users hit your production app with thousands of products, search results, or data rows, the performance cliff appears.
Understanding Virtualization: Only Render What's Visible
Luckily we can solve this with a concept called virtualization—also known as windowing or virtual scrolling. The core idea is beautifully simple: only render the items that are currently visible in the viewport, plus a small buffer zone above and below for smooth scrolling.
Think about it this way: if your container shows 10 items at a time, why render all 10,000? The user can only see 10 items anyway. Virtualization maintains the illusion of a long list while actually rendering maybe 20-50 DOM nodes total.
Here's how it works under the hood:
- Calculate visible range: Based on scroll position and container height, determine which items are visible
- Render only those items: Create DOM nodes only for visible items (plus overscan buffer)
- Absolute positioning: Position items using absolute positioning to create the illusion of a full list
- Update on scroll: Recalculate and re-render as the user scrolls
- Maintain scroll height: Use a spacer element to preserve the scrollable area height
Let me show you a simplified conceptual implementation to demonstrate the mechanics:
// ✅ CONCEPT: Only render visible items
function VirtualizedList({ items, itemHeight, containerHeight }) {
const [scrollTop, setScrollTop] = useState(0)
// Calculate which items are currently visible
const startIndex = Math.floor(scrollTop / itemHeight)
const endIndex = Math.ceil((scrollTop + containerHeight) / itemHeight)
const visibleItems = items.slice(startIndex, endIndex + 1)
return (
<div
style={{ height: containerHeight, overflow: 'auto' }}
onScroll={(e) => setScrollTop(e.target.scrollTop)}
>
{/* Spacer to maintain total scroll height */}
<div style={{ height: items.length * itemHeight }}>
{/* Only render visible items */}
{visibleItems.map((item, idx) => (
<div
key={item.id}
style={{
position: 'absolute',
top: (startIndex + idx) * itemHeight,
height: itemHeight,
width: '100%'
}}
>
{item.name}
</div>
))}
</div>
</div>
)
}This is a simplified example to illustrate the concept—production libraries handle edge cases, dynamic heights, and optimizations we're not showing here. But the fundamental idea remains: transform 10,000 DOM nodes into 20-50 DOM nodes while maintaining the user experience.
The magic happens when you realize that even with 100,000 items, you're still only rendering the same ~20-50 DOM nodes. The performance characteristics become constant regardless of dataset size. Wonderful!
react-window: The Lightweight Champion
When it comes to production-ready virtualization, react-window is the most popular choice in the React ecosystem. Created by Brian Vaughn (the same developer who built react-virtualized), it's a complete rewrite focused on simplicity and bundle size.
Key features:
- Tiny bundle size: Only 6kb minified
- Simple API:
<FixedSizeList>and<VariableSizeList>components - Battle-tested: Used by major companies in production
- Perfect for fixed-height lists: Optimal performance when all items have the same height
Here's how you implement the same product list with react-window:
// ✅ SOLUTION: Using react-window for 10,000 items
import { FixedSizeList } from 'react-window'
function ProductListVirtualized({ products }) {
const Row = ({ index, style }) => (
<div style={style}>
<ProductCard product={products[index]} />
</div>
)
return (
<FixedSizeList
height={600} // Container height in pixels
itemCount={products.length} // Total number of items
itemSize={120} // Height of each item in pixels
width="100%"
>
{Row}
</FixedSizeList>
)
}
// Result: Only renders ~20 DOM nodes
// Memory usage: ~5MB (98% reduction!)
// Scroll FPS: 60fps (butter smooth)The FixedSizeList component handles everything: scroll tracking, item positioning, viewport calculations, and re-rendering optimizations. You just tell it how tall each item is (itemSize), how many items you have (itemCount), and how to render each row (Row component).
Notice how the Row component receives two props: index and style. The index tells you which item to render, and style contains the absolute positioning needed to place the item correctly. You must apply the style prop to your row element—this is how virtualization works.

Before and after metrics:
| Metric | Before | After | Improvement |
|---|---|---|---|
| Initial render | 3500ms | 180ms | 19x faster |
| Memory usage | 250MB | 5MB | 98% reduction |
| Scroll FPS | 15fps | 60fps | 4x smoother |
| Lighthouse score | 12/100 | 94/100 | 7.8x better |
Similar to how caching improves performance, virtualization reduces unnecessary work by only processing what's needed. The difference is that caching optimizes data access, while virtualization optimizes rendering.
When should you use react-window? If all your list items have a fixed height and you need a simple, reliable solution with the smallest possible bundle size, this is your answer.
TanStack Virtual: The Modern Framework-Agnostic Solution
While react-window dominates the React ecosystem, @tanstack/react-virtual (part of the TanStack family of libraries) offers a modern alternative with some compelling advantages, especially for TypeScript projects and non-React frameworks.
Key features:
- Framework-agnostic core: Works with React, Vue, Solid, and Svelte through adapters
- Better TypeScript support: First-class TypeScript with superior type inference
- Dynamic heights out of the box: Automatically measures and adjusts to varying item heights
- Modern API: Uses hooks and observables for reactive updates
- Bundle size: ~8kb (still very lightweight)
The main difference is how it handles dynamic item heights. While react-window requires you to know heights upfront or implement complex measurement callbacks, TanStack Virtual measures items automatically and adjusts on the fly.
Here's the same product list with dynamic heights:
// ✅ TanStack Virtual with dynamic heights
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'
function DynamicHeightList({ items }) {
const parentRef = useRef(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100, // Initial estimate, auto-adjusts
overscan: 5, // Render 5 extra items above/below viewport
})
return (
<div
ref={parentRef}
style={{ height: '600px', overflow: 'auto' }}
>
<div
style={{
height: virtualizer.getTotalSize(),
position: 'relative'
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement} // Automatically measures this element
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
<DynamicContent item={items[virtualItem.index]} />
</div>
))}
</div>
</div>
)
}The key difference is the measureElement ref callback. TanStack Virtual uses this to measure each element's actual height after render, then recalculates positions accordingly. You provide an initial estimate (estimateSize), and it handles the rest.
This is incredibly powerful for content with variable heights: blog post previews, chat messages, search results with different layouts, or any scenario where you can't predict item heights upfront.
When to use TanStack Virtual:
- You need dynamic heights without manual measurement
- TypeScript is important to your project
- You want a framework-agnostic solution
- You prefer modern hooks-based APIs
- Bundle size difference (2kb) is acceptable
I cannot stress this enough—if you're building a TypeScript project with dynamic content heights, TanStack Virtual will save you hours of frustration compared to implementing height measurement callbacks manually.
react-virtuoso: The Feature-Rich Powerhouse
For developers who want the best developer experience and don't mind a slightly larger bundle, react-virtuoso is the most feature-complete virtualization library available. It handles edge cases that other libraries require manual work to solve.
Key features:
- Dynamic heights without configuration: Just works, no height estimation needed
- Built-in infinite loading: Scroll-to-load-more out of the box
- Scroll restoration: Automatically handles back/forward navigation
- Sticky headers and footers: Groups with pinned section headers
- Best developer experience: Less boilerplate, more "just works"
- Bundle size: ~15kb (larger, but includes more features)
Here's infinite loading with dynamic heights using Virtuoso:
// ✅ react-virtuoso: Infinite loading with dynamic heights
import { Virtuoso } from 'react-virtuoso'
import { useState, useCallback } from 'react'
function InfiniteProductList() {
const [products, setProducts] = useState([])
const [hasMore, setHasMore] = useState(true)
const loadMore = useCallback(async () => {
const newProducts = await fetchProducts(products.length)
setProducts(prev => [...prev, ...newProducts])
setHasMore(newProducts.length > 0)
}, [products.length])
return (
<Virtuoso
style={{ height: '600px' }}
data={products}
endReached={loadMore} // Called when user scrolls to bottom
itemContent={(index, product) => (
<ProductCard product={product} />
)}
components={{
Footer: () => hasMore ? <Loading /> : <EndOfList />
}}
/>
)
}Wonderful! No height configuration, no manual scroll detection for infinite loading, no complex setup. You pass your data array, define how to render each item, and provide a callback for loading more. Virtuoso handles everything else: dynamic measurement, smooth scrolling, loading states, and performance optimization.
The endReached callback fires automatically when the user scrolls near the bottom, making infinite scroll implementations trivial. Compare this to manually implementing intersection observers or scroll event handlers—the DX difference is substantial.
When to use react-virtuoso:
- Dynamic content with unpredictable heights (chat apps, social feeds, search results)
- Need infinite scroll without boilerplate
- Complex requirements (sticky headers, grouping, scroll restoration)
- Developer experience matters more than 7-9kb bundle difference
- Building content-heavy applications where the feature set justifies the size
The tradeoff is clear: you're trading bundle size for features and developer convenience. For many applications, especially content-heavy ones, this is absolutely the right choice.
Choosing the Right Library: Decision Matrix
Now that you understand all three options, how do you choose? Let me break down the decision-making process based on your specific needs.
Use react-window when:
✅ All items have fixed height - You know the height upfront ✅ Simple list requirements - No infinite scroll or complex features needed ✅ Bundle size is critical - Every KB matters (mobile-first apps) ✅ Battle-tested solution required - Widely used in production at scale
Use @tanstack/react-virtual when:
✅ Framework-agnostic needs - Building for React, Vue, or Solid ✅ TypeScript is important - Superior type inference and DX ✅ Dynamic heights required - Items have varying heights ✅ Modern API preferred - Hooks-based, reactive patterns
Use react-virtuoso when:
✅ Dynamic unpredictable heights - Content varies significantly ✅ Infinite scroll needed - Built-in scroll-to-load functionality ✅ Complex requirements - Sticky headers, grouping, scroll restoration ✅ Developer experience > bundle size - Prefer less boilerplate

Here's a comparison table across key dimensions:
| Feature | react-window | TanStack Virtual | react-virtuoso |
|---|---|---|---|
| Bundle Size | 6kb ⭐⭐⭐ | 8kb ⭐⭐⭐ | 15kb ⭐⭐ |
| Fixed Heights | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ |
| Dynamic Heights | ⭐ (manual) | ⭐⭐⭐ (auto) | ⭐⭐⭐ (auto) |
| TypeScript DX | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ |
| Infinite Scroll | ⭐ (manual) | ⭐⭐ | ⭐⭐⭐ (built-in) |
| Learning Curve | Easy | Medium | Easy |
| Framework Support | React only | Multi-framework | React only |
| Maintenance | Active | Very Active | Active |
Choosing the right tool is like simplifying large components—sometimes the simpler solution is better. Don't automatically reach for the most feature-rich option; match the tool to your actual requirements.
My general recommendation: Start with react-window for fixed heights, graduate to TanStack Virtual when you need dynamic heights with TypeScript, or jump straight to react-virtuoso if you need the full feature set and DX matters.
Real-World Patterns and Common Gotchas
Let me share some hard-won lessons from implementing virtualized lists in production. These patterns and pitfalls can save you hours of debugging.
Common Mistakes
1. Forgetting to memoize row components
// ❌ Row component re-renders on every parent update
function ProductList({ products }) {
const Row = ({ index, style }) => (
<div style={style}>
<ProductCard product={products[index]} />
</div>
)
return <FixedSizeList {...props}>{Row}</FixedSizeList>
}
// ✅ Memoized row prevents unnecessary re-renders
const Row = memo(({ index, style, data }) => (
<div style={style}>
<ProductCard product={data[index]} />
</div>
))
function ProductList({ products }) {
return (
<FixedSizeList itemData={products} {...props}>
{Row}
</FixedSizeList>
)
}Without memoization, every row re-renders whenever the parent component updates, killing performance. Use React.memo and pass data through itemData prop.
2. Not accounting for padding/borders in height calculations
// ❌ Height doesn't account for borders/padding
<FixedSizeList itemSize={100} {...props}>
// ✅ Include all spacing in height calculation
<FixedSizeList itemSize={100 + 16 + 2} {...props}>
// 100px content + 16px padding + 2px borderIf your items have padding, borders, or margins, include them in itemSize. Otherwise, scroll calculations will be wrong and you'll see visual glitches.
3. Missing key props
Always provide stable key props for list items. Virtualized lists recycle DOM nodes, so React needs keys to properly track and update elements. Use IDs from your data, not array indices if items can be reordered.
4. Accessibility: Missing ARIA roles
// ✅ Proper accessibility
<Virtuoso
role="list"
data={items}
itemContent={(index, item) => (
<div role="listitem" aria-posinset={index + 1} aria-setsize={items.length}>
{item.name}
</div>
)}
/>Screen readers need ARIA roles to understand virtualized lists. Add role="list" to the container and role="listitem" to items. For better UX, include aria-posinset (position) and aria-setsize (total count).
Testing Virtualized Lists
Testing virtualized components requires special consideration since only visible items render:
// Testing virtualized lists
import { render, screen } from '@testing-library/react'
import { Virtuoso } from 'react-virtuoso'
test('renders visible items only', async () => {
const items = Array.from({ length: 10000 }, (_, i) => ({
id: i,
name: `Item ${i}`
}))
render(
<Virtuoso
style={{ height: 300 }}
data={items}
itemContent={(index, item) => (
<div data-testid={`item-${index}`}>
{item.name}
</div>
)}
/>
)
// Only visible items are in the DOM
expect(screen.queryByTestId('item-0')).toBeInTheDocument()
expect(screen.queryByTestId('item-1')).toBeInTheDocument()
// Items far down the list are NOT in the DOM
expect(screen.queryByTestId('item-100')).not.toBeInTheDocument()
expect(screen.queryByTestId('item-9999')).not.toBeInTheDocument()
})When testing, remember: only visible items exist in the DOM. Don't expect to query elements that aren't currently rendered. Test the visible range and scroll behavior, not the entire dataset.
For more on React performance patterns, check out batching state updates to prevent render waterfalls in complex components.
Performance Monitoring
Use React DevTools Profiler to verify virtualization is working:
- Open React DevTools → Profiler tab
- Start recording
- Scroll through your list
- Stop recording and examine the flame graph
You should see only ~20-50 components rendering on each scroll event, regardless of total item count. If you see hundreds or thousands of renders, something is wrong.
Conclusion
Make no mistake about it—virtualization is not premature optimization. When building data-heavy applications, it's the difference between a smooth, professional experience and a janky, unusable mess. The libraries we covered (react-window, TanStack Virtual, and react-virtuoso) each solve real problems, and choosing the right one depends on your specific needs.
Start with react-window for simplicity and the smallest bundle size when you have fixed-height lists. Graduate to TanStack Virtual when you need dynamic heights with excellent TypeScript support and framework flexibility. Jump straight to react-virtuoso if you need the full feature set—infinite scroll, scroll restoration, sticky headers—and developer experience matters more than a few extra kilobytes.
Your users will thank you when they experience butter-smooth 60fps scrolling through thousands of items instead of watching their browser grind to a halt. The implementation cost is low, the performance gains are massive, and the user experience improvement is immediately noticeable.
Trust me, when you see a previously janky 10,000-item list transform into a silky-smooth experience with just 20 lines of code, you'll wonder why you didn't learn virtualization sooner. I know I did.
And that concludes the end of this post! I hope you found this valuable and look out for more in the future!
Continue Learning:
- The Power of Caching in JavaScript
- The Power of Simplifying Large Components in React Apps
- Teaching AI Agents to Batch React State Updates
Photo credits: Tiger Lily (Pexels), Lukas (Pexels), Leeloo The First (Pexels)