Skip to main content

Command Palette

Search for a command to run...

List Virtualisation — Rendering 10,000 Rows Without Killing the Browser

Day 23 of 40 — React System Design Series

Updated
8 min read
List Virtualisation — Rendering 10,000 Rows Without Killing the Browser
R

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! 👋

scroll gif

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.

scroll gif

// 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).

scroll gif

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.

scroll gif


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 container

  • getTotalSize() — gives the total height so the scrollbar is correct

  • getVirtualItems() — only the rows currently in view + overscan

  • position: 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.

scroll gif


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)

scroll gif


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

scroll gif

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.

fast gif

See you tomorrow for Day 24 — Bundle size matters: tree shaking, analysis, and what to do about it.

scroll gif


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.

React System Design — Learning in Public

Part 23 of 34

I'm a React developer with 5.5 years of experience who spent 2 years in Lit.js — and slowly forgot how React really works at a senior level. This series is my public journey back. Every day I cover one topic: state management, authentication, performance, system design interviews, testing — the real stuff that separates a mid-level dev from a senior. Written honestly, with real code, for developers at every level.

Up next

Bundle Size Matters — Tree Shaking, Analysis, and What to Do About It

Day 24 of 40 — React System Design Series