Topics
v6.0.0
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.

ApproachDOM nodesBest for
Naive renderO(n) — 100kNever at scale
Fixed-height windowO(visible + overscan)Trading tables, logs
Variable-height windowO(visible) + measure cacheMixed content rows
Canvas/WebGLO(1) DOMExtreme 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] only
function 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:

  1. Buffer ticks in a ref — don’t setState per message
  2. Flush once per frame via requestAnimationFrame
  3. Merge by row id — latest value wins
  4. 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 anchoring

Practical rules

  • Use @tanstack/react-virtual or react-window
  • Stable keys by row id
  • Batch WebSocket updates — one commit per frame
  • Subscribe visible rows only — not root store
  • contain: content on 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

Last updated on