Page Rendering Optimization (Virtual DOM, Virtual Scroll)
Slow rendering manifests specifically: a list of 10,000 rows hangs the browser when scrolling, a dashboard with charts lags when updating data, a modal opens with delay. Causes are always measurable — unnecessary re-renders, DOM with thousands of nodes, synchronous computations in render path. Optimization starts with profiling, not guessing.
Measurement Tools
Before any optimization — establish baseline:
React DevTools Profiler — record an interaction session, find components with high render duration and frequent re-renders. Pay special attention to why did this render — shows what changed.
Chrome Performance — Ctrl+Shift+P → Start profiling and reload page. In flame chart look for long tasks (>50ms), Recalculate Style, Layout thrashing.
// Quick check without DevTools
const start = performance.now();
// ... operation
console.log(`Took: ${performance.now() - start}ms`);
// For components — React Profiler API
import { Profiler } from 'react';
<Profiler id="ProductList" onRender={(id, phase, actualDuration) => {
if (actualDuration > 16) {
console.warn(`Slow render: ${id} took ${actualDuration}ms (${phase})`);
}
}}>
<ProductList />
</Profiler>
Virtual Scroll: Why DOM Kills Performance
1000 rows in a table = 1000 DOM nodes (plus cells). Browser keeps everything in memory, recalculates styles on any change, scroll handler iterates over all elements. At 5000+ rows, page starts lagging on any hardware.
Virtualization renders only visible rows + small overscan. On scroll — replaces content, keeping one set of DOM nodes.
TanStack Virtual (formerly react-virtual)
Headless — doesn't dictate styles, works with any CSS:
npm install @tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
interface VirtualListProps<T> {
items: T[];
itemHeight: number;
renderItem: (item: T, index: number) => React.ReactNode;
}
function VirtualList<T>({ items, itemHeight, renderItem }: VirtualListProps<T>) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => itemHeight,
overscan: 5, // how many rows to render outside visible area
});
return (
<div
ref={parentRef}
style={{ height: '600px', overflow: 'auto' }}
>
{/* Container with full height — for proper scrollbar */}
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{renderItem(items[virtualItem.index], virtualItem.index)}
</div>
))}
</div>
</div>
);
}
For variable-height rows — measureElement:
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80, // initial estimate
measureElement: (el) => el.getBoundingClientRect().height, // actual size
});
// In render row:
<div
ref={virtualizer.measureElement}
data-index={virtualItem.index}
>
{/* variable content */}
</div>
React Window for Tables
For tables with fixed row sizes react-window is simpler:
npm install react-window react-window-infinite-loader
npm install --save-dev @types/react-window
import { FixedSizeList } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
function VirtualTable({ data }: { data: Row[] }) {
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
<div style={style} className={`row ${index % 2 === 0 ? 'row--even' : ''}`}>
<div className="cell">{data[index].id}</div>
<div className="cell">{data[index].name}</div>
<div className="cell">{data[index].status}</div>
</div>
);
return (
<AutoSizer>
{({ height, width }) => (
<FixedSizeList
height={height}
itemCount={data.length}
itemSize={48}
width={width}
overscanCount={5}
>
{Row}
</FixedSizeList>
)}
</AutoSizer>
);
}
Eliminating Unnecessary Re-renders
Profiler shows component renders too often. Causes and solutions:
New objects/functions on every render:
// Problem: new function on every parent render
function Parent() {
const handleClick = (id: number) => doSomething(id); // new object
return <Child onClick={handleClick} />;
}
// Solution: useCallback
function Parent() {
const handleClick = useCallback((id: number) => doSomething(id), []);
return <Child onClick={handleClick} />;
}
memo for stable components:
const ProductCard = memo(function ProductCard({ product, onAddToCart }: Props) {
return (
<div className="card">
<h3>{product.name}</h3>
<button onClick={() => onAddToCart(product.id)}>Add to cart</button>
</div>
);
}, (prev, next) => {
// Custom comparator — render only if needed fields changed
return prev.product.id === next.product.id
&& prev.product.price === next.product.price
&& prev.onAddToCart === next.onAddToCart;
});
useMemo for expensive computations:
function ProductList({ products, filters }: Props) {
// Without useMemo — filtering on every re-render
const filtered = useMemo(
() => products.filter(applyFilters(filters)).sort(sortBy('price')),
[products, filters]
);
return filtered.map((p) => <ProductCard key={p.id} product={p} />);
}
Context: Isolating Updates
Context triggers re-render of all consumers on any value change. Solution — split contexts by update frequency:
// Bad: everything in one context
const AppContext = createContext({ user, cart, theme, settings });
// Good: split by update frequency
const UserContext = createContext(user); // rarely
const CartContext = createContext(cart); // often (per item)
const ThemeContext = createContext(theme); // very rarely
For high-frequency updates (price, ticker) — use-context-selector:
import { createContext, useContextSelector } from 'use-context-selector';
const StoreContext = createContext({ price: 0, volume: 0 });
// Re-renders only on price change, ignores volume
function PriceDisplay() {
const price = useContextSelector(StoreContext, (s) => s.price);
return <span>{price}</span>;
}
Deferred Rendering: React 18 Transitions
Heavy updates (filtering large list) shouldn't block input:
import { useTransition, useDeferredValue } from 'react';
function SearchWithHeavyList({ items }: { items: Item[] }) {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
// Updating query — urgent (show typed character)
// Updating filtered list — non-urgent
const deferredQuery = useDeferredValue(query);
const filtered = useMemo(
() => items.filter((item) => item.name.includes(deferredQuery)),
[items, deferredQuery]
);
return (
<>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
{isPending && <span className="loading-indicator" />}
<VirtualList items={filtered} itemHeight={48} renderItem={...} />
</>
);
}
Web Workers for Heavy Computations
Sorting/filtering a million records shouldn't block main thread:
// filter.worker.ts
self.onmessage = ({ data: { items, filters } }) => {
const result = items.filter(applyFilters(filters));
self.postMessage(result);
};
import { useEffect, useRef, useState } from 'react';
function useWorkerFilter(items: Item[], filters: Filters) {
const [filtered, setFiltered] = useState(items);
const workerRef = useRef<Worker>();
useEffect(() => {
workerRef.current = new Worker(
new URL('./filter.worker.ts', import.meta.url),
{ type: 'module' }
);
workerRef.current.onmessage = ({ data }) => setFiltered(data);
return () => workerRef.current?.terminate();
}, []);
useEffect(() => {
workerRef.current?.postMessage({ items, filters });
}, [items, filters]);
return filtered;
}
Layout Thrashing
Alternating reads and writes of DOM properties causes forced reflow:
// Bad: read → write → read → write = 4 reflows
const h1 = el1.offsetHeight;
el2.style.height = h1 + 'px';
const h2 = el3.offsetHeight;
el4.style.height = h2 + 'px';
// Good: all reads first, then all writes
const h1 = el1.offsetHeight;
const h2 = el3.offsetHeight;
el2.style.height = h1 + 'px';
el4.style.height = h2 + 'px';
// Or via requestAnimationFrame
requestAnimationFrame(() => {
const heights = elements.map(el => el.offsetHeight);
requestAnimationFrame(() => {
targets.forEach((el, i) => el.style.height = heights[i] + 'px');
});
});
Optimization Checklist
Before optimization measure with Profiler. Then by priority:
- Virtualize lists > 200 elements — largest impact
-
memo+useCallbackon components with expensive renders - Split contexts, isolate frequent updates
-
useDeferredValue/startTransitionfor non-urgent updates -
useMemofor expensive computations (> 1–2 ms) - Web Workers for CPU-intensive tasks
Timeline
- Performance audit (profiling, report with recommendations): 1 day
- Virtualize one heavy list/table: 1–2 days
- Comprehensive optimization (virtualization + eliminate re-renders + workers): 3–7 days
- Full performance refactor of large SPA: 2–4 weeks







