Virtualizing live-updating lists #
A log viewer, an order blotter or a chat history grows by dozens of rows a second from a WebSocket. With every row in the DOM, the page slows as the session goes on: layout gets more expensive with each insert, memory climbs, and eventually typing in the filter box lags by a second. The standard answer is virtualization — render only the rows in the viewport plus a small buffer — and the standard libraries handle static lists well. Live lists add two problems those libraries do not solve on their own: rows inserted above the viewport shift everything the user is reading, and a stream that never stops needs a policy for how much history to keep at all.
Root cause #
Rendering cost in a list is driven by the number of DOM nodes, not the number of items the user can see. Ten thousand rows is ten thousand sets of elements for the browser to style and lay out, even when thirty are visible. Live updates make it worse in two ways: each insert invalidates layout for the list, and inserting at the top — the natural place for newest items in a feed — pushes every existing row down by the new row’s height.
That push is the scroll-jumping problem. If the user has scrolled down to read an older entry and a new row arrives at the top, the browser keeps scrollTop constant while the content moves, so the entry they were reading slides out of view. Modern browsers apply scroll anchoring to mitigate this in normal documents, but virtualized lists position rows absolutely inside a sized container, which defeats the browser’s anchoring and leaves the correction to your code.
Resolution #
Use a virtualization library for the windowing — TanStack Virtual is shown here — and add three live-list behaviours on top. Stick to the edge when the user is there: if the user is at the newest end, keep them there as rows arrive. Anchor when they are not: if they have scrolled away, compensate the scroll offset by the height of rows inserted above so what they are reading stays put, and show a “new items” pill instead of moving them. Bound the history: cap the array and drop the oldest items, so neither memory nor the scroll range grows forever.
import { useVirtualizer } from '@tanstack/react-virtual';
import { useLayoutEffect, useRef, useState } from 'react';
const ROW_ESTIMATE_PX = 32;
const MAX_ITEMS = 5_000; // bounded history: oldest items are dropped
const AT_TOP_THRESHOLD_PX = 8; // "the user is at the newest end"
type Row = { id: string; text: string };
export function LiveList({ incoming }: { incoming: Row[][] /* batches, newest first, one per frame */ }) {
const parentRef = useRef<HTMLDivElement>(null);
const [items, setItems] = useState<Row[]>([]);
const [unseen, setUnseen] = useState(0);
const pendingShift = useRef(0); // height inserted above the viewport since last layout
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => ROW_ESTIMATE_PX,
overscan: 5,
getItemKey: (i) => items[i].id, // keys, not indexes: rows keep identity on insert
});
// Apply the newest batch (called once per animation frame upstream).
const applyBatch = (batch: Row[]) => {
const el = parentRef.current;
const atTop = !el || el.scrollTop <= AT_TOP_THRESHOLD_PX;
setItems((prev) => [...batch, ...prev].slice(0, MAX_ITEMS));
if (!atTop) {
pendingShift.current += batch.length * ROW_ESTIMATE_PX; // compensate after render
setUnseen((n) => n + batch.length);
}
};
// After the DOM updates, push the scroll position down by what was inserted above,
// so the rows the user is reading stay exactly where they were.
useLayoutEffect(() => {
if (pendingShift.current && parentRef.current) {
parentRef.current.scrollTop += pendingShift.current;
pendingShift.current = 0;
}
}, [items]);
const jumpToNewest = () => {
parentRef.current?.scrollTo({ top: 0 });
setUnseen(0);
};
return (
<div className="live-list">
{unseen > 0 && <button className="new-pill" onClick={jumpToNewest}>{unseen} new</button>}
<div ref={parentRef} className="live-list-viewport" onScroll={(e) => {
if (e.currentTarget.scrollTop <= AT_TOP_THRESHOLD_PX) setUnseen(0);
}}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((v) => (
<div key={v.key} data-index={v.index} ref={virtualizer.measureElement}
className="live-list-row" style={{ transform: `translateY(${v.start}px)` }}>
{items[v.index].text}
</div>
))}
</div>
</div>
</div>
);
}
measureElement lets rows have their real heights; the scroll compensation uses the estimate for the frame of insertion, and the virtualizer corrects positions once real heights are measured. For lists with highly variable row heights, compensate using measured sizes from the virtualizer’s cache instead of the estimate. Feed the list one batch per animation frame, as described in batching WebSocket updates with requestAnimationFrame, so inserts and scroll corrections happen once per paint rather than once per message.
Updates to existing rows — a status changing, a price ticking — are cheap with stable keys: only the row component whose data changed re-renders, and only if it is currently in the window. Rows outside the window cost nothing to update because they are not rendered at all.
Edge cases #
Newest at the bottom. Chat-style lists put the newest items at the bottom, where appending does not shift existing rows, so no compensation is needed — but loading older history at the top does shift them. Apply the same compensation when prepending history, and stick to the bottom when the user is already there.
Bounded history and anchoring. Dropping the oldest items from the end of a newest-first list does not move visible rows unless the user is reading near the very end. If they are, pause trimming until they scroll back, or trim in larger, less frequent chunks.
Accessibility. Screen readers cannot see rows that are not rendered. Announce new items through an aria-live region with a summary (“5 new orders”) rather than relying on the list itself, and make sure keyboard navigation moves focus to rendered rows. Detached rows that remain referenced after virtualization unmounts them are a leak risk too; see detecting detached DOM nodes from real-time lists.
Verification #
Stream a few thousand items into the list and check the DOM: document.querySelectorAll('.live-list-row').length should stay near viewport rows plus overscan. Scroll down to an older item, let new items stream in for ten seconds, and confirm the item you were looking at does not move and the pill counts the arrivals. Record a Performance profile during the stream: layout time per frame should stay flat as the list grows, instead of increasing with every insert.
Operational checklist #
FAQ #
Why does my list jump when new WebSocket items arrive? #
Items inserted above the viewport push existing rows down while the scroll position stays the same, so content moves under the reader. Virtualized lists defeat the browser’s scroll anchoring, so compensate the scroll offset yourself by the inserted height.
Which virtualization library should I use? #
TanStack Virtual, react-window and react-virtuoso are all solid for React; Vue and Svelte have equivalents. react-virtuoso has built-in support for prepending items and following output, which covers much of the live-list behaviour described here.
How many items can I keep in memory? #
The array itself is cheap — tens of thousands of small objects cost a few megabytes. The real limit is usefulness: nobody scrolls through fifty thousand log lines. Cap history and offer search or pagination against the server for older data.
Does virtualization work with variable row heights? #
Yes, with measurement: render rows, measure them, and cache sizes so positions are exact. Estimates are used until a row has been measured, which is why scroll compensation is most accurate with measured sizes.
Related #
- Batching WebSocket Updates with requestAnimationFrame — one insert per frame.
- Detecting Detached DOM Nodes from Real-Time Lists — leaks in non-virtualized lists.
- Vue 3 Real-Time Dashboard Best Practices — bounded collections in Vue.
- Reconciling Snapshots and Deltas over WebSockets — loading history before the stream.
Back to Real-Time Rendering Performance.