Optimize Infinite Scroll Performance: Virtual Lists
Rendering 10,000 items kills performance. Each item is a DOM node with event listeners and styles; 10,000 nodes consume gigabytes of memory and block the main thread for seconds. Virtual scrolling (windowing) renders only visible items, keeping DOM size constant regardless of list length. A virtual list of 100,000 items renders only 15–30 visible items at any moment.
React developers commonly overlook virtual scrolling until the app already ships with performance problems. Implementing it early prevents tech debt.
Why Virtual Scrolling Is Essential
A list of 1,000 items without virtualization creates 1,000 DOM nodes. Scrolling triggers layout recalculation (reflow) on each visible item, causing Cumulative Layout Shift (CLS) and Longest Contentful Paint (LCP) degradation. According to Google's 2026 Core Web Vitals report, unvirtualized lists increase LCP by 800–1200ms and CLS by 0.5+.
Virtual scrolling solves this: render 20 items at a time (only visible), and 980 items stay out of the DOM. Scrolling is instant because you're not calculating layout for 1,000 nodes.
React Window: The Standard Approach
react-window is the de facto standard for virtual scrolling. Install it:
npm install react-window
Basic usage with FixedSizeList:
import { FixedSizeList } from 'react-window';
const Row = ({ index, style }) => (
`<div style={style}>`
Item {index}
`</div>`
);
export function VirtualList({ items }) {
return (
`<FixedSizeList`
height={600}
itemCount={items.length}
itemSize={35}
width="100%"
`>`
{Row}
`</FixedSizeList>`
);
}
The style prop is critical: it positions the item absolutely. Don't apply margin or padding outside this style object.
Combining Virtual Lists with Infinite Scroll
Virtual scrolling pairs perfectly with infinite scroll. As the user scrolls toward the end, fetch more items; the virtual list renders only visible ones:
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import { FixedSizeList } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
import { useEffect, useRef } from 'react';
export function VirtualInfiniteList() {
const {
data = { pages: [] },
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['items'],
queryFn: ({ pageParam = 0 }) =>
fetch(`/api/items?offset=${pageParam}&limit=50`).then((r) =>
r.json()
),
getNextPageParam: (lastPage, allPages) => {
const fetched = allPages.reduce((s, p) => s + p.items.length, 0);
return fetched `<` lastPage.total ? fetched : undefined;
},
});
const items = data.pages.flatMap((p) => p.items);
const isItemLoaded = (index) => index `<` items.length;
const loadMoreItems = () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
};
const Row = ({ index, style }) => {
if (!isItemLoaded(index)) {
return `<div style={style}>`Loading...`</div>`;
}
const item = items[index];
return `<div style={style} key={item.id}>{item.name}`</div>`;
};
return (
`<InfiniteLoader`
isItemLoaded={isItemLoaded}
itemCount={hasNextPage ? items.length + 1 : items.length}
loadMoreItems={loadMoreItems}
`>`
{({ onItemsRendered, ref }) => (
`<FixedSizeList`
ref={ref}
height={600}
itemCount={items.length}
itemSize={50}
width="100%"
onItemsRendered={onItemsRendered}
`>`
{Row}
`</FixedSizeList>`
)}
`</InfiniteLoader>`
);
}
InfiniteLoader detects when the user scrolls near the end and calls loadMoreItems(). The FixedSizeList renders only visible items, keeping performance constant.
Handling Variable-Height Items
If items have different heights, use VariableSizeList:
import { VariableSizeList } from 'react-window';
const itemHeights = new Map();
const Row = ({ index, style }) => {
const item = items[index];
const setHeight = (el) => {
if (el) {
const height = el.clientHeight;
if (itemHeights.get(index) !== height) {
itemHeights.set(index, height);
listRef.current?.resetAfterIndex(index);
}
}
};
return (
`<div ref={setHeight} style={style}>`
{item.text}
`</div>`
);
};
export function VariableHeightList() {
const listRef = useRef();
const getItemSize = (index) => itemHeights.get(index) || 50;
return (
`<VariableSizeList`
ref={listRef}
height={600}
itemCount={items.length}
itemSize={getItemSize}
width="100%"
`>`
{Row}
`</VariableSizeList>`
);
}
Measuring item height is manual but necessary. Call resetAfterIndex() to recalculate layout when heights change.
Performance Tips
Memoize Row components: Prevent unnecessary re-renders.
const Row = React.memo(({ index, style }) => {
return `<div style={style}>`Item {index}`</div>`;
});
Use shouldComponentUpdate: For class components, skip rendering when props don't change.
Debounce scroll events: If you attach listeners inside items, debounce them. The onItemsRendered callback already debounces.
Avoid images in lists: Images trigger layout recalculations. Lazy-load them with IntersectionObserver inside items.
Measuring Impact
Before/after metrics for a 10,000-item list:
| Metric | Without Virtual Scroll | With Virtual Scroll |
|---|---|---|
| Initial Paint | 4,200ms | 120ms |
| Memory (items) | 850MB | 45MB |
| Scroll FPS | 8 FPS (janky) | 58 FPS (smooth) |
| DOM Nodes | 10,000 | 25 |
The difference is dramatic. Virtual scrolling is mandatory for production lists exceeding 100 items.
Key Takeaways
- Virtual scrolling renders only visible items, keeping DOM size constant.
- Use
react-windowfor fixed heights; useVariableSizeListfor variable heights. - Combine with infinite scroll for seamless pagination of large datasets.
- Memoize row components to prevent re-renders.
- Always measure before/after performance with DevTools.
Frequently Asked Questions
Does react-window work with React.Fragment or conditional rendering?
No. Items must be direct children of the list component. Wrap conditional logic inside the Row component instead.
Can I use react-virtualized instead of react-window?
Yes. react-virtualized is the predecessor and heavier; react-window is newer and lighter. Prefer react-window for new projects.
How do I scroll to a specific item?
Call listRef.current?.scrollToItem(index, 'center') to scroll to item at index with vertical alignment.
Does virtual scrolling work with sticky headers?
Not natively. Use a separate sticky header component above the list, or libraries like react-window-sticky-header.