List Virtualisation — Rendering 10,000 Rows Without Killing the Browser
Day 23 of 40 — React System Design Series

Hi, I’m Richa — a Senior Frontend Engineer with 5+ years of experience building scalable, production-grade web interfaces for enterprise and consumer applications. I work primarily with React, TypeScript, and modern frontend architectures, focusing on component systems, performance, and maintainability. Most of my experience comes from building real-world products in regulated domains like banking and insurance, where clarity, reliability, and long-term ownership matter more than quick demos. Through this blog, I write about frontend engineering fundamentals, scalable UI design, problem-solving, and the lessons I’ve learned working on large codebases. My goal is to share practical insights — not shortcuts — for developers who want to grow strong engineering foundations. I also mentor early-career developers and strongly believe that curiosity, asking the right questions, and understanding why something works are more important than memorizing tools. If you’re serious about improving as an engineer, you’re in the right place.
Hey everyone, Richa here! 👋

Day 23. Story time.
I once got a bug report that said: "the transactions page takes 8 seconds to load and then freezes when you scroll."
The page was rendering a table with 5,000 rows. Every row. In the DOM. All 5,000 of them.
I looked at the component and it was just a map() over the array. Perfectly reasonable React code for 50 items. Catastrophic for 5,000.

// Perfectly fine for 50 items. Disaster for 5,000.
{transactions.map(tx => <TransactionRow key={tx.id} tx={tx} />)}
The fix was list virtualisation. It took two hours to implement and reduced render time from 8 seconds to under 100ms.
Today we learn how it works and when to use it.
Why rendering 5,000 rows kills performance
When you render 5,000 <TransactionRow /> components:
React creates 5,000 component instances
The browser creates 5,000+ DOM nodes (each row has multiple elements)
Each DOM node takes memory
The browser has to do layout calculations for all 5,000 rows
Scrolling means the browser re-paints thousands of elements
JavaScript event listeners might be attached to each row
The user can only see maybe 15–20 rows at once. But the browser is managing the memory and layout of all 5,000.
Virtualisation solves this by only rendering the rows that are currently visible in the viewport — plus a small buffer above and below. As the user scrolls, rows are added to the bottom of the virtual list and removed from the top (and vice versa).

Without virtualisation: 5,000 DOM nodes rendered
With virtualisation: ~20 DOM nodes rendered, swapped as user scrolls
The list still appears to have 5,000 rows (the scroll height is correct). But the DOM only ever contains ~20 at any moment.
The library — TanStack Virtual (and react-window)
Two main options in the React ecosystem:
TanStack Virtual (@tanstack/react-virtual) — headless, flexible, pairs with TanStack Query
npm install @tanstack/react-virtual
react-window — simpler API, slightly easier to get started
npm install react-window
We'll use TanStack Virtual — it's the modern default and pairs naturally with React Query which we covered on Day 9.

Basic virtualisation — a fixed-height row list
The simplest case: a list where every row is the same height.
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
type Transaction = {
id: string;
date: string;
description: string;
amount: number;
status: 'pending' | 'completed' | 'failed';
};
function TransactionList({ transactions }: { transactions: Transaction[] }) {
// The scrollable container
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: transactions.length, // total number of items
getScrollElement: () => parentRef.current,
estimateSize: () => 56, // estimated row height in px
overscan: 5, // render 5 extra rows above/below viewport
});
return (
// The scroll container — fixed height, overflow scroll
<div
ref={parentRef}
style={{ height: '600px', overflow: 'auto' }}
>
{/* Total height container — makes the scrollbar the correct size */}
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{/* Only render virtualised items */}
{virtualizer.getVirtualItems().map(virtualItem => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<TransactionRow transaction={transactions[virtualItem.index]} />
</div>
))}
</div>
</div>
);
}
function TransactionRow({ transaction }: { transaction: Transaction }) {
return (
<div className={`transaction-row status-${transaction.status}`}>
<span>{transaction.date}</span>
<span>{transaction.description}</span>
<span className="amount">
{transaction.amount < 0 ? '-' : '+'}
£{Math.abs(transaction.amount).toFixed(2)}
</span>
<span className={`badge ${transaction.status}`}>{transaction.status}</span>
</div>
);
}
The key pieces:
parentRef— attached to the scroll containergetTotalSize()— gives the total height so the scrollbar is correctgetVirtualItems()— only the rows currently in view + overscanposition: absolute+translateY— positions each row at the right vertical offset
Variable height rows — the more realistic case
Real-world lists often have variable row heights — comments with different amounts of text, cards with different content. TanStack Virtual handles this too:
function CommentList({ comments }: { comments: Comment[] }) {
const parentRef = useRef<HTMLDivElement>(null);
// Map from index to measured height
const measureCache = useRef<Record<number, number>>({});
const virtualizer = useVirtualizer({
count: comments.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80, // initial estimate — will be corrected after measurement
measureElement: (element) => element.getBoundingClientRect().height, // measure actual height
overscan: 3,
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map(virtualItem => (
<div
key={virtualItem.key}
data-index={virtualItem.index} // needed for measureElement
ref={virtualizer.measureElement} // virtualizer measures the actual rendered height
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
<CommentCard comment={comments[virtualItem.index]} />
</div>
))}
</div>
</div>
);
}
measureElement tells TanStack Virtual to measure the actual rendered height of each row and adjust positions accordingly. The list remains accurate even when heights vary wildly.

Combining virtualisation with infinite scroll
This is the killer combination for any large data list. Pair TanStack Virtual with React Query's useInfiniteQuery:
import { useInfiniteQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
function InfiniteTransactionList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['transactions'],
queryFn: ({ pageParam = 0 }) =>
transactionService.getPage({ page: pageParam, limit: 50 }),
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
initialPageParam: 0,
});
// Flatten all pages into a single array
const allTransactions = data?.pages.flatMap(page => page.transactions) ?? [];
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: hasNextPage ? allTransactions.length + 1 : allTransactions.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 56,
overscan: 5,
});
const virtualItems = virtualizer.getVirtualItems();
// Detect when user has scrolled near the bottom — load more
useEffect(() => {
const lastItem = virtualItems[virtualItems.length - 1];
if (!lastItem) return;
if (
lastItem.index >= allTransactions.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
}, [virtualItems, allTransactions.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 > allTransactions.length - 1;
return (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{isLoaderRow ? (
isFetchingNextPage ? <RowSkeleton /> : null
) : (
<TransactionRow transaction={allTransactions[virtualItem.index]} />
)}
</div>
);
})}
</div>
</div>
);
}
This combination gives you:
Only ~20 DOM nodes rendered at any time (virtualisation)
Data loaded in pages of 50 as the user scrolls (infinite query)
A loading skeleton row at the bottom while the next page loads
This is exactly how Twitter's timeline, Gmail's inbox, and most enterprise data grids work.
When virtualisation is worth it — and when it's not
Use virtualisation when:
Your list has more than 100–200 items rendered at once
Users scroll through large datasets (transactions, logs, users)
You're seeing scroll jank or high memory usage in DevTools
The list items are complex (lots of DOM nodes per row)
Don't bother when:
Your list has fewer than 100 items — the overhead isn't worth it
You're using pagination (only showing 20 items per page) — already handled
List items are extremely simple (just text) — the browser handles it fine
You need to support complex accessibility patterns (virtualisation makes ARIA harder)

Quick summary
| Problem | Solution |
|---|---|
| Rendering 5,000+ items in the DOM | Virtualise with TanStack Virtual |
| Fixed-height rows | estimateSize: () => rowHeight |
| Variable-height rows | measureElement for accurate measurement |
| Large list + load more | TanStack Virtual + useInfiniteQuery |
| Scroll jank | Add overscan: 5 to pre-render rows before they enter view |
Today's takeaway

List virtualisation is one of those techniques where the impact is dramatic and immediate. 8 seconds → 100ms is a real number from a real project.
The mental model: the browser renders what's visible, not what exists. getTotalSize() gives the scrollbar the correct height so the user feels like all the data is there. getVirtualItems() gives you only the slice the user can actually see.
Use it whenever you have more than ~200 items in a list. Combine it with useInfiniteQuery for the full production pattern.
See you tomorrow for Day 24 — Bundle size matters: tree shaking, analysis, and what to do about it.

← Day 22 — Code Splitting and Lazy Loading → Day 24 — Bundle Size and Tree Shaking
Part of the React System Design Series — 40 days, one topic per day.






