Back to blog
Article

Ten thousand rows in an admin table without freezing the tab

Ten thousand rows in an admin table without freezing the tab
S

StriveBit

4 min readWeb Applications

Ten thousand rows in an admin table without freezing the tab

An operations manager at a logistics client exports 12,000 shipment records into the admin panel and clicks the tab to sort by dispatch date. The tab hangs for nine seconds on her Redmi Note 9. We get the screenshot.

The data is already in memory — fetched as JSON, sitting in a Redux slice. The bottleneck is the DOM. React is reconciling 12,000 `<tr>` nodes, the browser is laying them out, and the sort triggers a full re-render. On a desktop Chrome with 16GB RAM this takes 400ms. On a mid-range Android phone with 4GB and Chrome 112, the main thread blocks for long enough that Android shows the "wait or close" dialog.

The first fix is usually not virtualization. It is pagination, and most teams resist it because the product owner wants infinite scroll. Infinite scroll on an admin table is a UX choice that costs you performance, accessibility, and the ability to print. We push back, ship server-side pagination at 50 rows per page, and the freeze disappears. The operations manager can still filter by date range and export the full set to CSV. She never needed to see 12,000 rows on one screen.

When the client genuinely needs a long scroll — a reconciliation view where the auditor wants to eyeball consecutive rows without clicking "next page" every five seconds — we reach for windowing. `@tanstack/react-virtual` on top of a flat list, with `overscan` set to 4 rows. The table renders roughly 30 DOM nodes regardless of data size. Sort happens in memory, the virtualizer re-renders only the visible window, and the tab stays responsive.

The implementation is about 40 lines. The hard part is row height. If your rows wrap text, the virtualizer needs a fixed height or a `measureElement` callback. Dynamic measurement works, but it adds a layout pass per row on first scroll. On the Redmi, that first scroll stutters for about 800ms until heights stabilize. We set a fixed `rowHeight` of 48px, truncate long cell content with `text-overflow: ellipsis`, and put the full value in a `title` attribute. The stutter goes away.

Virtualization is not worth it when the table is small enough that the browser can handle it without complaint. We tested this on the Redmi: 500 rows render in under 300ms and scroll smoothly. 1,000 rows take about 1.2 seconds on initial render but scroll fine after that. If your admin view caps at a few hundred rows — and most do, once you add date filters — skip the virtualizer. You are adding a dependency, a measurement step, and a layer of indirection for a problem the browser already solves.

There is a middle ground we use sometimes: render the first 200 rows immediately, then append the rest in chunks of 200 using `requestIdleCallback`. The table appears instantly, and the full set fills in over two or three seconds without blocking interaction. It is not as clean as virtualization, but it avoids the row-height problem entirely and works with existing table markup.

One thing we stopped doing: rendering cells with nested components that each subscribe to a slice. A table with 12,000 rows and eight cells per row means 96,000 component instances. Even if each cell is a simple `<td>`, the reconciliation cost compounds. We flatten to a single `<tr>` per row that reads from a plain object, and we memoize the row component with `React.memo` and a custom comparator that checks the row ID and a `updatedAt` timestamp. If the sort does not change row data, only order, we shuffle keys instead of re-rendering.

The logistics client's reconciliation view now loads 12,000 rows in under 200ms and scrolls at 60fps on the Redmi. The operations manager has not sent us a screenshot since.

Back to all articles

Ready to build something great?

We help ambitious teams build software that lasts. If you're interested in working with us or want to discuss your project, let's connect.

Get in touch