What 10,000 rows actually means for a React interface

Ronnie Kiyegga
Ronnie Kiyegga

When a React table gets slow, the first fixes people reach for are memoising rows or changing the API. Sometimes they help. Often they don’t, because fetching the data and rendering it are two different costs.

Fetching 10,000 records can take a few hundred milliseconds. Rendering them means React creating thousands of elements, and the browser laying out and painting every DOM node, even though the screen only shows a few dozen rows.

In this article, I’ll show how to work out which part of that pipeline is slow, when virtualisation is the right fix and when the better answer is not sending 10,000 rows to the browser at all.

Ten thousand is a rendering problem

This is the most common implementation:

<tbody>  {events.map((event) => (    <EventRow key={event.id} event={event} />  ))}</tbody>

The code is reasonable. The problem is scale.

If the viewport only shows a few dozen rows, mounting thousands of EventRow components is work for content the user cannot currently see. React still walks the list. The browser still creates nodes, computes layout, and paints.

The pipeline is API, then JavaScript, then React render, then DOM, then layout, then paint. A fast API and a slow table can coexist. Request timing and rendering time are different measurements.

10,000 is not a threshold where React suddenly falls over. A simple row on a desktop machine behaves differently from a dense row with nested controls on a work laptop. Row complexity, DOM structure, device, browser and how often the list updates all change the cost.

Measure before choosing the optimisation

"The table is slow" is not enough information. It does not tell you whether you are waiting on the network, on React, or on the browser.

The Network panel shows request and transfer time. If the payload is large or the endpoint is slow, the rest of the pipeline has not even started.

React Profiler shows render and commit behaviour. If a poll or a filter rerenders thousands of rows, that is a React cost.

The Performance panel shows main-thread work: scripting, layout, paint. A long layout or paint after a commit is the browser paying for the DOM you just built.

DOM inspection answers a blunt question: how many nodes did we actually mount? If tbody contains 10,000 rows and the user can see a few dozen, you already know a lot.

I use those views to put the cost in one part of the pipeline before I pick a fix. For example, a 40ms query followed by a 400ms long task on the main thread still makes a slow screen.

Window the work

If the diagnosis is excessive DOM and render work, the useful move is to stop mounting rows the user cannot see.

Virtualisation keeps the records you already have, and only renders a window: the visible rows plus a small overscan so scrolling does not flash empty space. The dataset can still be 10,000 records. What changes is how much UI exists at once.

The snippet below uses TanStack Virtual as an example, and it only makes sense once profiling has put the cost on DOM and render work.

const ROW_HEIGHT = 44;const virtualizer = useVirtualizer({  count: events.length,  getScrollElement: () => viewportRef.current,  estimateSize: () => ROW_HEIGHT,  getItemKey: (index) => events[index].id,  overscan: 10,});const visibleRows = virtualizer.getVirtualItems();

These rows are fixed-height, so estimating their position is straightforward. If the row height were dynamic, I'd need to measure it rather than relying on the same estimate for every row.

getItemKey gives the virtualizer a stable identity for each row. By default it uses the index, which is harmless for fixed-height rows like these. Once rows are measured rather than estimated, the virtualizer caches each measured size against that key, so filtering, sorting or prepending can attach a cached height to the wrong record. Keying by id keeps the measurement with the row it belongs to. The React key on each rendered row is a separate concern, and should still be the record's id.

The rows above and below are still in events. They are not in the document.

That is also the cost. A row that is not in the document cannot be found with the browser's find-in-page. A focused row that scrolls out of the window unmounts and takes keyboard focus with it. Assistive technology no longer sees the whole table unless the row count and each row's position are supplied deliberately. Dynamic row heights add their own difficulty, because positions depend on measurements that have not been taken yet, so the scroll position can jump.

Virtualisation solves rendering pressure, but it is not free. I would reach for it when profiling has put the cost on rendering, then check the interactions the table has to keep: finding a record, moving through rows with the keyboard, keeping a selection while scrolling and what a screen reader announces. If people mainly need to locate one record, server-side search may serve them better than a smoother scroll through all of them.

But should 10,000 records be in the browser?

Virtualisation answers the render question. It does not answer whether the browser should have received 10,000 records in the first place.

If the user is browsing a historical log, I would rather page or cursor the API:

GET /events?limit=50&cursor=eyJpZCI6IjEwMDAifQ==

Pagination controls how much data we retrieve. Virtualisation controls how much UI we render. They are not competing solutions. A large enough interface can use both: a paged or filtered payload, then a windowed list if that payload is still too tall to mount.

The right split depends on what the screen is for. Browsing a historical dataset usually wants pagination. Search usually wants server-side filtering. An operational feed may stream new records rather than download a slab. A dashboard may need aggregates, not raw rows.

If the interaction never required the full set, shrinking the request is the cheaper fix.

Find the cost, then choose the tool

Pagination, server-side filtering, virtualisation, caching, memoisation when a profile shows it is worth it, a worker for genuinely heavy client CPU and transitions for non-urgent rendering all exist for real bottlenecks in data-heavy screens. They are not a stack you apply together. Each one addresses a different cost.

A large dataset does not automatically mean virtualisation, useMemo, workers or pagination. First establish where the cost is.

  • If the browser is mounting thousands of nodes the user cannot see, reduce the rendered window.
  • If you are transferring more data than the interaction needs, reduce the dataset before it reaches React.
  • If computation is blocking the main thread, look at the computation.

A 40ms query followed by a 400ms main-thread task is still a slow screen. A fast API is not the same thing as a responsive interface, and the fix belongs wherever the time is actually spent.

Other articles