Tables at 10,000 rows, where performance is UX
Most tables in most products are short. A hundred rows, tops. The design work is real but bounded: column hierarchy, cell alignment, sort indicators, empty states. Standard craft.
Then you hit an import tool, a user admin, a log viewer, a CRM export, and the row count jumps to five digits. The design problem changes underneath you. You can still draw the same table. But if you render the same table, it dies. Scroll becomes molasses. Typing into a filter drops half the keystrokes. The app feels broken, even though no single pixel is wrong.
That’s the design-engineer moment. The fix is a technique called virtualization, and it changes what you’re willing to let a user do. Here’s one with 10,000 rows:
Scroll it. Type into the filter. Click the column headers to sort. Every interaction stays fast because the table lies to you: there are never more than a few dozen <div>s in the DOM at any given moment. The rest of the 10,000 rows are pure data, rendered only when they scroll into view. The footer on the demo confirms it.
Why naive rendering dies
A naive table renders every row. 10,000 rows at ~5 DOM nodes each is 50,000 nodes. The browser’s layout engine treats that as a single rendering problem and pays the cost on every paint.
You feel this as three separate failures:
- Initial paint is slow. Mounting the table freezes the tab for a beat.
- Scroll is janky. Even without state changes, the browser has to relayout the world on scroll.
- Every interaction compounds. Typing into a filter updates state, which re-renders all 10,000 rows, which blocks the next keystroke.
Users don’t report “table is slow.” They stop using features. They don’t filter. They don’t sort. They scroll once, realise it’s painful, and bounce. The UX and the performance are the same thing.
Virtualization, in one paragraph
The trick is to separate the data from what’s rendered. An outer container gets a fixed height: enough for one viewport. An inner sizer gets a height equal to totalRows × rowHeight, so the scrollbar behaves like the full table exists. On scroll, you calculate the window of rows that would actually be visible and render only those, absolutely positioned at translateY(firstVisibleRow × rowHeight). A buffer of a few rows above and below keeps the illusion whole.
Ten thousand rows collapse into roughly twelve rendered elements, re-used as you scroll. The DOM never grows; the scroll is never asked to lay out more than a viewport.
What this unlocks for design
Performance isn’t the point. What performance unlocks is the point:
- Filter as you type, always. No debounce, no “Search” button. Every keystroke recomputes and re-paints in under a frame.
- Sort on any column without a loading state. Because the sort is an in-memory array operation over a stable dataset, not a network round trip and not a layout tax.
- Showing scale in the UI becomes cheap. “1,284 of 10,000 rows” tells the user what they’re doing. The footer in the demo is a design decision, not decoration.
- The user can trust the dataset is live. If scroll is smooth at 10,000 rows, they stop wondering whether the remaining data was paginated away.
Every one of those decisions is a design decision. None of them survive if the table takes 900 ms to respond to a keystroke.
The trade-offs you inherit
Virtualization isn’t free. The things that break:
- Ctrl-F in the browser. You can’t text-search rows that don’t exist in the DOM. The filter input becomes your Ctrl-F, and it has to be visible, focused easily, and keyboard-reachable.
- Variable row height. The math above assumes rows are all the same height. Variable heights need extra accounting (measuring on render, caching, re-offsetting on scroll). Doable, but it’s where most custom virtualizers get messy.
- Screen reader announcements. Off-screen rows aren’t in the accessibility tree. Users relying on “read whole table” get the wrong mental model. The fix is real column labels, a row-count announcement on filter/sort, and ARIA live regions where appropriate.
All of these are solvable. But they stop being “the table designer’s job” and start being “the design engineer’s job.” That’s where this kind of work lives.
A rule of thumb
If your product has lists, tables, logs, audit trails, contact lists, or imports, at some scale performance stops being an engineering concern and becomes a design concern. The right question isn’t “can the table handle 10,000 rows?” It’s “what does the interface let the user do because the table can handle 10,000 rows?”
Answer that, and you’re designing for the real product. Answer it with “they’ll paginate,” and you’re designing a smaller one.