Skip to main content

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:

MetricWithout Virtual ScrollWith Virtual Scroll
Initial Paint4,200ms120ms
Memory (items)850MB45MB
Scroll FPS8 FPS (janky)58 FPS (smooth)
DOM Nodes10,00025

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-window for fixed heights; use VariableSizeList for 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.

Further Reading