100K Rows, Zero Lag.
returnA React performance case study: start with a slow 100,000-row table, profile the browser, fix one bottleneck at a time, and measure after every change.
Stack: React + TypeScript + Vite. Tools: Chrome DevTools Performance, React DevTools Profiler, Performance API, Web Workers.
The Setup
My table felt completely fine with a few thousand rows. Then I pushed the same idea to 100,000 rows — scrolling turned laggy, filtering took noticeable time, sorting blocked the page, and even selecting a row could freeze the UI for a moment.
The easy move would have been sprinkling useMemo, useCallback, and
React.memo everywhere. But I didn't actually know what was slow yet.
So I made it a lab:
- Build an intentionally unoptimized version.
- Measure what the browser is doing.
- Fix only the biggest bottleneck.
- Measure again. Repeat.
I generated a seeded 100,000-row dataset (same records, same order in every test — otherwise comparisons are meaningless):
type Employee = {
id: number;
name: string;
email: string;
department: string;
role: string;
salary: number;
status: "active" | "inactive";
};
The lab has four modes, each solving a different problem:
Baseline → Virtualized → Optimized → Worker
Separate modes matter: change everything at once and you'll never know which fix did what.
Baseline: Render Everything
The baseline renders all 100,000 rows the naive way:
<tbody>
{rows.map((row) => (
<TableRow key={row.id} row={row} />
))}
</tbody>
Fine for 500 rows. At 100K, one row becomes ~6 DOM elements, so the browser ends up managing hundreds of thousands of nodes. The data isn't the problem — rendering all of it as DOM at once is.
Three symptoms showed up: expensive initial render (huge React tree + style/layout/paint), janky scrolling (massive document to maintain), and filtering that blocked interaction (synchronous pipeline on the main thread). One complaint — "the table is slow" — but three different bottlenecks, which profiling made obvious.
Profile Before Optimizing
I recorded focused traces in Chrome DevTools → Performance, one question per recording: reload, fast scroll, search, sort by salary, select a row. The traces showed a wall of scripting/rendering on load, far more browser work than necessary during scroll, and one long main- thread task on filter. The baseline pipeline explained why:
100,000 rows → filter ×3 → transform → sort → render
plus repeated string normalization and throwaway objects. Three targets fell out directly:
Huge DOM → virtualization
Wasted React work → rendering optimization
Heavy sync compute → pipeline + Web Worker
No more guessing.
I also measured inside the lab with the Performance API, so every number
in the panel is a real measurement — no hardcoded "800ms → 20ms" labels,
no setTimeout fake slowness:
performance.mark("filter:start");
const result = runFilterPipeline(rows, filters);
performance.mark("filter:end");
performance.measure("filter", "filter:start", "filter:end");
Fix #1: Virtualization
The most visible bottleneck: rendering 100,000 rows when the viewport shows ~30. Virtualization keeps the dataset but renders only a window — visible rows plus a few overscan rows above and below so fast scrolling never shows blank space:
Dataset: 100,000 → virtual window → DOM rows: ~36
Initial render, DOM size, scrolling, and layout all improved immediately. But filtering was still expensive — expected, and useful: virtualization fixed a rendering problem, not a computation problem.
A large dataset and a large DOM are not the same problem.
Virtualized mode — the dataset stays at 100,000 rows while the DOM holds ~36.
Fix #2: Stop Re-rendering Every Row
With ~36 mounted rows, the next question was: when I click one row, what re-renders? The classic trap — selection state at the table level with an inline callback:
<TableRow
row={row}
selected={selectedId === row.id}
onSelect={() => setSelectedId(row.id)} // new fn every render
/>
One click could re-render every row. The fix is a stable callback plus a memoized row:
const handleSelect = useCallback((id: number) => {
setSelectedId(id);
}, []);
const TableRow = React.memo(function TableRow({ row, selected, onSelect }) {
// ...
});
React DevTools Profiler confirmed it: row selection went from many
renders to ~2 (old selection + new selection). One caveat I kept:
React.memo isn't free — if every prop changes anyway, or the
component is cheap, the comparison costs more than it saves. Profile
first, memoize second.
Fix #3: The Data Pipeline
Smaller DOM, fewer renders — filtering still hurt. The naive pipeline ran multiple passes with throwaway arrays over 100K rows, and search re-normalized strings on every keystroke:
row.name.toLowerCase().includes(query.toLowerCase()) // ×100,000
Fixes: precompute a lowercase searchText per row at dataset creation,
merge filters into a single pass, sort the smaller filtered result, and
use comparators that fit the data (a.salary - b.salary for numbers;
localeCompare only when locale behavior is actually required).
This is also where useMemo finally earned its place:
const processedRows = useMemo(() => {
return processRows(rows, filters, sort);
}, [rows, filters, sort]);
Memoization isn't a coding style — it's for expensive derivations with
stable inputs that profiling proves are recomputed. The rule I kept:
it's not about using useMemo, it's about which repeated work you're
eliminating.
Optimized mode — filter+sort down to 4.30ms, measured live, not hardcoded.
Fix #4: Get Off the Main Thread
Even cheap computation blocks the UI if enough of it runs synchronously — the main thread also handles style, layout, paint, and input. A long filter task means clicks wait. So the fourth mode moves the pipeline into a Web Worker:
worker.postMessage({ type: "PROCESS_ROWS", payload: { rows, filters, sort } });
self.onmessage = (event) => {
const result = processRows(event.data.payload);
self.postMessage({ type: "PROCESS_COMPLETE", payload: result });
};
The key insight: a Worker targets responsiveness, not raw speed. If
main-thread takes 20ms and the Worker takes 24ms (serialization isn't
free), the Worker still wins — the UI stays interactive during
computation. For production I'd also init the worker once, keep the
dataset in the worker and send only { query, department, sort },
debounce search, and ignore stale responses via request IDs.
Worker mode, no filter — 100,000 rows in 2.90ms without touching the main thread.
Worker mode with a real filter — Engineering + Active narrows 100,000 rows to 3,165 in 8.10ms, off the main thread.
The Final Architecture
Data, processing, and rendering — fully separated:
The browser still holds 100,000 records. It just never renders 100,000 rows, never re-renders all of them on one click, never repeats avoidable work, and never blocks input on heavy computation.
Results
No fake benchmarks — fill this from one machine, one browser, one dataset, one procedure:
Browser / version / machine / CPU / RAM / build / throttling
Dataset size: 100,000 (seeded, identical every run)
| Metric | Baseline | Virtualized | Optimized | Worker |
|---|---|---|---|---|
| Rendered rows | TBD | TBD | TBD | TBD |
| DOM nodes | TBD | TBD | TBD | TBD |
| Initial render | TBD | TBD | TBD | TBD |
| Filter + sort | TBD | TBD | TBD | TBD |
| Renders per selection | TBD | TBD | TBD | TBD |
| Long Tasks | TBD | TBD | TBD | TBD |
Rules I followed: production build (npm run build && npm run preview),
same 4× CPU throttle (or same no-throttle) across modes, same query and
sort, reset state between runs, multiple runs per test — and React
Profiler for render counts, Chrome Performance for browser work.
Measure It Right, Ship It Right
Two rules kept the numbers honest. First, profile the production
build (npm run build && npm run preview) — Strict Mode double-
invokes, source maps, and dev warnings all distort what you see in
development. Second, repeat key tests at 4× CPU throttle: on a fast
laptop some bottlenecks barely register, and throttling answers "what
happens on hardware slower than mine?" — as long as every mode uses the
same setting. Comparing throttled baseline against unthrottled optimized
proves nothing.
And one honest caveat: this lab keeps 100,000 records client-side on purpose, to study frontend performance. In a real product I'd ask whether the server should filter, whether cursor pagination beats shipping every row, whether we need every column, and whether virtualization breaks keyboard navigation and screen-reader behavior. Performance isn't the only requirement.
The Loop Worth Keeping
Notice → Reproduce → Measure → Find the bottleneck →
Understand the cost → Change one thing → Measure again
Works for slow dashboards, heavy charts, large forms, animation jank — the tools change, the loop doesn't.
Don't start with the optimization. Start with the measurement.