On this page · sections
Virtualise 100k Live Rows
Rendering 100,000 DOM rows is impossible at 60fps — virtualization (windowing) mounts only visible rows plus a small overscan buffer, keeping DOM count O(viewport) regardless of data size.
| Approach | DOM nodes | Best for |
|---|---|---|
| Naive render | O(n) — 100k | Never at scale |
| Fixed-height window | O(visible + overscan) | Trading tables, logs |
| Variable-height window | O(visible) + measure cache | Mixed content rows |
| Canvas/WebGL | O(1) DOM | Extreme density — harder a11y |
Why Naive Rendering Fails
Definition
100k <tr> elements means:
- ~100k layout objects — scroll triggers massive reflow
- GC pressure from React reconciliation on every tick
- Memory: each node ≈ KB — hundreds of MB
- INP collapse on any interaction
// Anti-pattern — 100k rows
function NaiveTable({ rows }) {
return (
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td>{row.symbol}</td>
<td>{row.price}</td>
<td>{row.change}</td>
</tr>
))}
</tbody>
);
}Rule of thumb: keep mounted DOM under ~1,000 nodes for smooth scroll on mid-tier mobile. Virtualization is mandatory for live data feeds at scale.
Interview: Virtualization reduces DOM to O(visible), not O(data). 100k rows ≠ 100k nodes.
Fixed-Height Windowing
Definition
Assume uniform row height (e.g. 32px). Calculate visible range from scrollTop:
startIndex = floor(scrollTop / rowHeight)
endIndex = startIndex + ceil(viewportHeight / rowHeight) + overscan
render rows[startIndex..endIndex] onlyfunction VirtualList({
items,
rowHeight = 32,
overscan = 5,
height,
}: {
items: { id: string; symbol: string; price: number }[];
rowHeight?: number;
overscan?: number;
height: number;
}) {
const [scrollTop, setScrollTop] = useState(0);
const start = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan);
const visibleCount = Math.ceil(height / rowHeight) + overscan * 2;
const end = Math.min(items.length, start + visibleCount);
const offsetY = start * rowHeight;
const totalHeight = items.length * rowHeight;
return (
<div
style={{ height, overflow: 'auto' }}
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
>
<div style={{ height: totalHeight, position: 'relative' }}>
<div style={{ transform: `translateY(${offsetY}px)` }}>
{items.slice(start, end).map((item) => (
<div key={item.id} style={{ height: rowHeight }}>
{item.symbol} — {item.price}
</div>
))}
</div>
</div>
</div>
);
}Use @tanstack/react-virtual or react-window in production — handles edge cases, horizontal scroll, sticky headers. Stable keys by row id, never index.
Interview: Fixed height = simple math. Total scroll height =
items.length * rowHeight. TranslateY positions the window.
Batch WebSocket Updates
Definition
Live feeds can burst 1,000+ updates/sec. Applying each synchronously causes long tasks and frame drops.
Strategy:
- Buffer ticks in a ref — don’t setState per message
- Flush once per frame via
requestAnimationFrame - Merge by row id — latest value wins
- Immutable data store outside React — subscribe per visible row
const pendingUpdates = new Map();
let rafId = null;
function onWebSocketMessage(updates) {
for (const u of updates) {
pendingUpdates.set(u.id, u);
}
if (!rafId) {
rafId = requestAnimationFrame(flushUpdates);
}
}
function flushUpdates() {
rafId = null;
const batch = Array.from(pendingUpdates.values());
pendingUpdates.clear();
dataStore.applyBatch(batch); // notify only affected visible rows
}For extreme throughput, diff in a Web Worker — postMessage changed ids only; main updates visible cells.
Granular subscriptions: visible row components subscribe to their id — not root re-render on every tick.
Interview: One state commit per frame, not per WebSocket message. Latest value wins per row id.
Variable Height and Scroll Anchoring
Definition
Variable row heights require measurement cache — measure rendered rows, store heights by id, estimate unmeasured rows.
// TanStack Virtual handles dynamic sizing
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 32,
measureElement: (el) => el.getBoundingClientRect().height,
});Scroll anchoring: when rows above viewport change height or new rows prepend, browser overflow-anchor: auto (default) prevents jump. For prepend-heavy feeds, preserve scroll position manually:
function prependRows(newRows) {
const el = scrollContainerRef.current;
const prevHeight = el.scrollHeight;
dataStore.prepend(newRows);
requestAnimationFrame(() => {
el.scrollTop += el.scrollHeight - prevHeight;
});
}Sticky column headers: render header outside scroll container or use position: sticky on <thead>. Test on iOS Safari — momentum scroll + sticky has quirks.
Interview: Variable height = measure cache + estimateSize. Prepend requires scroll compensation.
Trading Dashboard End-to-End
Definition
Production architecture for 100k live rows:
WebSocket → Worker (normalize + diff)
→ postMessage changedIds (max 50/frame)
→ Immutable store (Map by id)
→ Virtual list (TanStack Virtual, ~20 DOM rows)
→ Row component subscribes to single id
→ rAF-batched DOM text updates
→ CSS: contain: content on rows.row {
contain: content;
content-visibility: auto;
}Perf budget: max 16ms script per frame, max 50 DOM updates per tick. CI: synthetic scroll test with 100k dataset + mock WebSocket at 500 msg/sec.
OffscreenCanvas for sparkline charts in cells — optional; text-only updates are cheaper. React Compiler reduces re-render scope but doesn’t replace virtualization.
Interview: Virtualization + granular subscriptions + rAF batching + worker diff. Measure nodes and layout ms per frame.
Senior Interview Summary
Mental model
100k data rows → ~20 DOM nodes (virtual window)
WebSocket burst → buffer → merge by id → flush per rAF
Variable height → measure cache + scroll anchoringPractical rules
- Use
@tanstack/react-virtualorreact-window - Stable keys by row id
- Batch WebSocket updates — one commit per frame
- Subscribe visible rows only — not root store
contain: contenton row elements- Worker diff for high-frequency feeds
What to say in interviews
“I’d use TanStack Virtual with fixed or dynamic row height, keeping ~20 mounted nodes. WebSocket updates go into a Map keyed by id, flushed once per rAF. Each visible row subscribes to its id so a tick doesn’t re-render the whole table. For 500+ msgs/sec I’d diff in a worker and postMessage only changed ids.”
Common traps
- Index as key when rows reorder/prepend
- setState on every WebSocket message
- Virtualizing without granular subscriptions — still re-renders all visible
- Forgetting scroll anchoring on prepend-heavy feeds