When events arrive faster than React can render
Topics: react · performance · tables
The first answer to a slow, large table is usually virtualization. Render only the rows inside the viewport, cut the DOM count, and move on. That works for a static dataset. It does not solve a screen that keeps receiving new events.
Those events may arrive through a WebSocket or another stream. Each message is parsed, normalized, stored, selected, and eventually rendered. If that whole path runs once for every event, a fast virtualized table will still stutter.
Start with the data path
Before changing the table component, I draw the route taken by one message:
stream → parse → normalize → store → selector → visible rowsThen I measure each part. Is parsing expensive? Does every message create a new array containing the whole dataset? Does a filter rerun over every stored event? Is the browser spending its time in layout? React gets blamed because the failure is visible in the interface, even when the expensive work happened two steps earlier.
One pattern causes trouble quickly: calling setState for every incoming message. That makes the stream rate and the React update rate the same thing. They should not be coupled.
Buffer messages before notifying React
I put incoming messages into a plain buffer outside React, then move the batch into the store once per animation frame:
const buffer: SecurityEvent[] = [];
socket.onmessage = (event) => {
buffer.push(parse(event.data));
};
function drain() {
if (buffer.length > 0) {
store.append(buffer.splice(0, buffer.length));
}
requestAnimationFrame(drain);
}The important part is not requestAnimationFrame itself. It is that the source can deliver one hundred messages while the interface sees one update. React can subscribe through useSyncExternalStore or a narrow selector so unrelated components do not wake up for every batch.
That gives the system two different rates: ingestion and presentation. I want that separation in place before trying smaller rendering tricks.
If the stream can outrun the browser for a sustained period, the buffer also needs a policy. It may cap the batch size, drop low-value updates, or ask the transport for backpressure. The right answer depends on the product. What matters is making the choice explicit instead of letting memory grow until the tab fails.
Virtualization solves one problem
Virtualization reduces DOM nodes. It does not stop expensive work over thousands of records before the visible range is known.
If every render sorts the full list, applies a filter to every row, builds derived objects, and only then passes the result into the virtualizer, most of the cost still exists. I keep the source data in a store, index the fields used often, and compute the visible range as late as possible.
A simple fixed-height calculation may be enough:
const start = Math.floor(scrollTop / rowHeight);
const end = start + Math.ceil(viewportHeight / rowHeight) + overscan;
const visibleRows = rows.slice(start, end);Fixed row heights are not always possible, but they are worth considering in a dense operational table. Variable measurement adds work and can make scroll anchoring harder. If rows need expandable details, I usually keep the compact row fixed and open the details in a panel or a separate expanded region.
Keys matter too. A row key must be stable and must not depend on its current array index. Live insertions otherwise make React reuse the wrong row and can turn a performance bug into a correctness bug.
Memory is not only a future problem
It is easy to say, “we will cap the list later.” The browser pays for retained data now. Large object graphs increase garbage-collection work, and a pause caused by collection looks exactly like a rendering hitch to the person using the screen.
I do not keep every server object in its original shape. I normalize the fields the interface needs and avoid copying the full list on every append. Old data should have a clear policy: remove it, page it to another store, or fetch it again when the user moves outside the live window.
For a rolling list, chunks can be simpler than one ever-growing array. New records enter the latest chunk. Old chunks can be removed without copying everything that remains. The exact structure is less important than avoiding an allocation proportional to the entire history for every update.
Memory measurements belong next to frame measurements. A table can hold 60 FPS during a short demo and still become unusable after twenty minutes because its retained state never stops growing.
Keep the user's current row still
Appending at the top creates a different problem: the row under the pointer moves every time a new event arrives. A technically fast screen can still be impossible to investigate.
When the user is at the live edge, following new events is useful. Once they scroll away, I stop moving the viewport. New events collect behind a small “new events” control. The user can return to the live edge when ready.
The anchor should be an event ID, not only a pixel offset. When the dataset changes, I locate that event again and preserve its position. Pixel math alone becomes unreliable when rows are inserted or removed above the viewport.
Filtering needs the same care. If a filter changes while data continues to arrive, I do not silently reset the person's position and selection. I decide what remains selected, which row gets focus, and whether the live feed is paused. These are interaction decisions, not only rendering details.
When I use a Web Worker
I do not move parsing into a Web Worker by default. Message transfer has a cost, and React and DOM work remain on the main thread.
A worker becomes useful when profiling shows sustained CPU work in parsing, decompression, normalization, or building search indexes. I send batches rather than one tiny message at a time, and I measure the serialization cost. If the worker only saves two milliseconds and adds three milliseconds of copying, the architecture got more complicated without getting faster.
Selectors can be more valuable than a worker. A component that receives one boolean or one row ID is cheaper than a component that receives a newly allocated list and decides what matters on every render.
The benchmark must resemble the screen
I keep a repeatable input and measure it outside a production incident:
let last = performance.now();
function tick(frame: number) {
const now = performance.now();
if (frame > 32) report("slow-frame", frame);
last = now;
requestAnimationFrame(tick);
}Frame duration alone is not enough. I put it beside buffer lag, memory use, and the age of the newest event visible on screen. Those numbers reveal whether the interface is genuinely keeping up or merely looking smooth because it has stopped showing current data.
I run the same stream through the same filters and column configuration after every change. A small synthetic table with no active filter does not represent the expensive screen.
The order of work matters: separate ingestion from rendering, measure the whole data path, virtualize the DOM, control retained memory, and preserve the user's anchor. Only then do I reach for workers or more elaborate data structures.