All writing
3 min read

Rendering 50,000 Rows Without Losing 60fps

A reconciliation table with a 90-day window, live status updates and analysts who scroll fast. Here is how we kept it interactive instead of turning it into a loading spinner.

  • Performance
  • React
  • Rendering
Rendering 50,000 Rows Without Losing 60fps

The reconciliation screen in our gateway lets an analyst pull a 90-day window. At peak that is roughly fifty thousand settlement rows, each with a status that can change while it is on screen.

The first version fetched all of it and rendered all of it. The page took eleven seconds to become interactive and then dropped to about four frames per second on scroll. Here is what actually fixed it, in the order it mattered.

1. Only render what is on screen#

Virtualisation is the big one and it is not close. Fifty thousand rows is roughly fifty thousand DOM subtrees; a viewport holds about twenty.

The non-obvious part is not the virtualiser — it is that row height must be known before render. Variable heights force measurement, measurement forces layout, and layout on scroll is exactly the thing you are trying to avoid.

tsx
const rowVirtualizer = useVirtualizer({
  count: rows.length,
  getScrollElement: () => scrollRef.current,
  estimateSize: () => 44,      // fixed: no measurement pass
  overscan: 8,                 // small: enough to hide fast scroll, not more
});

An overscan of 8 instead of the default 20 cut our per-frame work by more than half with no visible difference. Overscan is a comfort blanket most tables do not need.

2. Stop re-rendering every row on every update#

Live status updates were the second problem. A single WebSocket message updated one row, and every row re-rendered, because the row list was derived inline in the parent.

Two changes fixed it:

  • Memoised row components that receive primitives, not objects. row={row} re-renders on every parent render even when nothing changed; status={row.status} does not.
  • A normalised store keyed by id, so updating a row touches exactly one entry.
tsx
const SettlementRow = memo(function SettlementRow({
  id,
  reference,
  amount,
  status,
}: SettlementRowProps) {
  // Only this component re-renders when the status changes.
  return (
    <div className="row">
      <span>{reference}</span>
      <Amount value={amount} />
      <StatusPill status={status} />
    </div>
  );
});

The rule I use: a memoised component that accepts an object prop is not memoised. It just looks like it is.

3. Batch the stream, do not render the stream#

Three thousand events per minute is fifty per second. Fifty React commits per second is survivable but wasteful, and it makes the table impossible to read because rows keep twitching.

We buffer incoming events and flush on an animation frame:

ts
const pending = new Map<string, SettlementRow>();
let frame = 0;

function enqueue(row: SettlementRow) {
  pending.set(row.id, row);
  if (frame) return;
  frame = requestAnimationFrame(() => {
    applyBatch(pending);
    pending.clear();
    frame = 0;
  });
}

One commit per frame, coalesced by id. If a row changes three times in a frame, we render it once — the last value. Under load this took us from ~50 commits/second to ~60 attempts that collapse into at most one per frame.

4. Animate transforms, never layout#

The status pill animates on change. The first version animated the pill's width, which meant a layout pass for every visible row on every update.

Now it animates transform and opacity only, on a fixed-size element. Nothing in the table animates a property that participates in layout.

That rule is worth stating plainly, because it is the one that gets broken most often in code review:

PropertyCost
transform, opacityComposited — usually free
background-color, colorPaint only
width, height, top, leftLayout, then paint

5. Make the numbers visible in CI#

None of the above survives a year without measurement. We added a Lighthouse check and a bundle ceiling to the pipeline, and a dev-only frame counter for the table.

If a performance rule is not enforced by a machine, it is a preference. Preferences lose to deadlines every time.

The result#

  • Scroll holds 60fps with a 50k-row window loaded.
  • Largest contentful paint on the reconciliation route dropped from 4.1s to 1.2s on mid-tier hardware.
  • Median time to resolve a dispute fell 41%, because analysts stopped waiting for the tool.

The last number is the only one leadership cared about, and it is the one I would lead with next time.