🇷🇺 Русская версия (тема 05 в подготовке к интервью): 05. Performance optimization в React
From the job posting: "Understanding of performance optimization techniques in React (lazy loading, memoization, virtualization)."
High-load web app, mobile-first - performance is not optional, it's a requirement.
Interviewers will ask about real cases: "Your feed with 10k messages is lagging — what do you do?"
Checklist
5.1. Rule Number One: Measure First
The most costly mistake is optimizing by gut feeling. Wrapping everything in useMemo, scattering useCallback across components — that's not optimization, it's the appearance of work. Find the bottleneck first, then fix it precisely.
Diagnostic Tools
React DevTools Profiler
- Open the Profiler tab in React DevTools
- Record the problematic interaction — study the flamegraph
- Color = render time: yellow (slow) → gray (didn't render at all)
- In settings, enable "Highlight updates when components render" — see everything that flickers
// Programmatic access to profiler data:
import { Profiler } from 'react';
<Profiler id="Feed" onRender={(id, phase, actualDuration) => {
console.log({ id, phase, actualDuration });
}}>
<Feed />
</Profiler>Chrome Performance
- DevTools → Performance → Record
- Reproduce the problematic action → Stop
- Look for:
Bundle Analyzer
# Vite
npm i -D rollup-plugin-visualizer
# Webpack
npm i -D webpack-bundle-analyzerThe treemap shows what's actually taking up space in your bundle. Surprise: often 40% is the entire lodash, moment.js with all locales, or the full @mui/icons-material.
5.2. Core Web Vitals — The Metrics That Matter
How to Improve Each Metric
LCP — "Make the page feel loaded quickly"
- Hero image:
<link rel="preload" as="image" href="..."> <img fetchpriority="high">for the key image- Fonts:
font-display: swap+ preload - CDN for static assets, SSR/SSG for first paint
- Reduce JS on the critical path
INP — "Make the page feel alive"
- Eliminate long tasks from handlers (debounce, defer, split up)
startTransition/useDeferredValuefor non-urgent updates- Heavy computations → Web Worker
scheduler.postTask()/scheduler.yield()in modern browsers
CLS — "Make sure buttons don't run away from the cursor"
- Always set
width/heighton<img>(oraspect-ratio) - Reserve space for dynamic content via
min-height, skeletons - Don't inject banners into the DOM after main content without reserved space
Measuring in Code
import { onCLS, onINP, onLCP } from 'web-vitals';
onLCP(console.log);
onINP(console.log);
onCLS(console.log);
// Send to your analytics — not just console.log in prod5.3. Causes of Unnecessary Re-renders — Breaking It Down
1. New reference in props of a React.memo component
const Child = React.memo(ChildImpl);
function Parent() {
// memo is useless — every render creates a new object and function
return <Child config={{ size: 10 }} onClick={() => doStuff()} />;
}
// Fix:
const config = useMemo(() => ({ size: 10 }), []);
const handleClick = useCallback(() => doStuff(), []);
<Child config={config} onClick={handleClick} />2. Context provider with a new value on every render
// Bad — object recreated on every render
<UserCtx.Provider value={{ user, setUser }}>
// Good — stable reference
const value = useMemo(() => ({ user, setUser }), [user]);
<UserCtx.Provider value={value}>3. State too high in the tree
If modal state lives in App, the entire App re-renders on every open. Move state down to where it's actually needed.
4. Objects and arrays in hook deps
// Every render = new array → effect fires every time
useEffect(() => { ... }, [filters]);
// Better: decompose to primitives
useEffect(() => { ... }, [filters.status, filters.search]);5. Unstable keys in lists
// Catastrophic
items.map((item, i) => <Row key={`${i}-${Math.random()}`} />)
// Bad for insertions in the middle
items.map((item, i) => <Row key={i} />)
// Correct
items.map(item => <Row key={item.id} />)5.4. React.memo, useMemo, useCallback — When They're Actually Needed
These tools are not free. React still creates functions, compares deps, stores in memory. For cheap computations, the overhead costs more than the savings.
useMemo — when it's justified
// 1. Expensive computation
const sortedUsers = useMemo(
() => users.slice().sort(complexCompareFn),
[users]
);
// 2. Stable reference for a memo component below
const config = useMemo(() => ({ size: 10, mode: 'auto' }), []);
<ExpensiveChart config={config} />
// 3. Stable reference as a dependency of another hook
const options = useMemo(() => ({ retry: 3 }), []);
useEffect(() => fetchWith(options), [options]);useCallback — when it's justified
// Only when the function is passed to a React.memo component
// or is a dependency of another hook
const Child = React.memo(ChildImpl);
const handleClick = useCallback(() => setCount(c => c + 1), []);
<Child onClick={handleClick} />React.memo — when it helps
- Component is expensive to render
- Parent renders often, props rarely change
- Props are primitives or stable references
// Custom comparator (rarely needed)
const Row = React.memo(RowImpl, (prev, next) => (
prev.id === next.id && prev.name === next.name
));
// Returns true = "equal, do NOT re-render" — opposite of shouldComponentUpdate, don't confuse themAnti-patterns — what not to do
// 1. Memo without measurement — an illusion of optimization
const Button = React.memo(({ onClick, children }) => (
<button onClick={onClick}>{children}</button>
));
// If children = <Icon /> — new React element every time → memo is useless
// 2. useCallback without memo children
const handler = useCallback(() => setX(1), []);
return <div onClick={handler} />; // div re-renders regardless
// 3. Memoizing cheap computations
const sum = useMemo(() => a + b, [a, b]); // overhead > savings5.5. List Virtualization
Problem: 10,000 rows = 10,000 DOM nodes. Expensive mount, blocked main thread. Only ~20 visible on screen at a time.
Solution: render only visible items + a small overscan buffer at the edges.
Choosing a Library
- TanStack Virtual — modern headless choice, maximum flexibility
- react-window — simple, for fixed/variable row heights
- react-virtuoso — better for chats and feeds, handles unknown heights out of the box
import { useVirtualizer } from '@tanstack/react-virtual';
function BigList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 64, // estimated row height
overscan: 5, // render +5 rows above/below for smoothness
});
return (
<div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((v) => (
<div
key={v.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: v.size,
transform: `translateY(${v.start}px)`,
}}
>
{items[v.index].name}
</div>
))}
</div>
</div>
);
}Key gotchas:
overflow: autois required on the scroll containerposition: absolute+transformfor rows (faster thantop/left)- If height is unknown —
measureElementrecalculates after render - For chats with reverse scroll — Virtuoso is more convenient
5.6. Code Splitting
By Route — the baseline
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
<Suspense fallback={<Spinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>By Component — for heavy widgets
const MarkdownEditor = lazy(() => import('./MarkdownEditor'));
{showEditor && (
<Suspense fallback={<EditorSkeleton />}>
<MarkdownEditor />
</Suspense>
)}By Library — for rare operations
async function exportToPdf(data) {
const { jsPDF } = await import('jspdf'); // loads only when "Export" is clicked
const doc = new jsPDF();
// ...
}Preload on Hover
const Dashboard = lazy(() => import('./Dashboard'));
<Link
to="/dashboard"
onMouseEnter={() => {
import('./Dashboard'); // browser starts fetching the chunk early
}}
>
Dashboard
</Link>Handling ChunkLoadError
const Dashboard = lazy(() =>
import('./Dashboard').catch(() => ({
default: () => <div>Failed to load. Please refresh the page.</div>,
}))
);Common deploy bug: the user has the old page open, a new deploy removed old chunks — dynamic import throws ChunkLoadError. Catch it in an Error Boundary and offer a reload.5.7. Bundle Size — Hunting Down Dead Weight
Classic Culprits
lodash
// Bad: pulls in the entire lodash
import _ from 'lodash';
_.debounce(fn, 300);
// Good: only what you need
import debounce from 'lodash/debounce';
// Or the ESM version — better for tree shaking
import { debounce } from 'lodash-es';moment.js → date-fns or dayjs
moment.js is frozen, heavy, and bundles all locales. date-fns is tree-shakeable, dayjs is a lightweight drop-in replacement.
Icon Libraries
// Bad: entire @mui/icons-material
import { Delete } from '@mui/icons-material';
// Good: just the icon you need
import Delete from '@mui/icons-material/Delete';What Breaks Tree Shaking
- CommonJS dependencies — bundler can't analyze statically
- Side effects in modules — need
"sideEffects": falseinpackage.json import * as X from 'mod'— named imports work better- Code with side effects at module top level (polyfills, prototype patches)
Power move: block PRs in CI that grow the bundle by more than N% — use size-limit.5.8. Lazy Loading Images and Resource Hints
Native Lazy Loading
<img src="photo.jpg" loading="lazy" decoding="async" width="800" height="600" />
<!-- width/height are required — otherwise CLS -->srcset for Retina and Mobile
<img
src="photo-800.jpg"
srcset="photo-400.jpg 400w, photo-800.jpg 800w, photo-1600.jpg 1600w"
sizes="(max-width: 600px) 100vw, 50vw"
alt="..."
/>Resource Hints — Hints for the Browser
<link rel="preconnect" href="https://api.example.com"> <!-- DNS+TCP+TLS early -->
<link rel="dns-prefetch" href="https://cdn.example.com"> <!-- DNS only -->
<link rel="preload" as="font" href="/fonts/inter.woff2" crossorigin>
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high">
<link rel="prefetch" href="/next-page.js"> <!-- low priority, for future navigation -->Image Formats
- AVIF — best compression, supported in modern browsers
- WebP — universal choice (30% lighter than JPEG)
- JPEG/PNG — fallback
<picture>
<source srcset="hero.avif" type="image/avif" />
<source srcset="hero.webp" type="image/webp" />
<img src="hero.jpg" alt="..." width="1200" height="600" />
</picture>5.9. Web Workers — Move the Heavy Lifting Off the Main Thread
When to use: CPU-intensive tasks — parsing large JSON, compression, sorting millions of elements, cryptography, diff algorithms, image processing.
// main.js
const worker = new Worker(new URL('./heavy.worker.ts', import.meta.url), { type: 'module' });
worker.postMessage({ data: bigArray });
worker.onmessage = (e) => setResult(e.data);
// heavy.worker.ts
self.onmessage = (e) => {
const result = heavyComputation(e.data.data);
self.postMessage(result);
};Comlink — Clean RPC over a Worker
// worker.ts
import * as Comlink from 'comlink';
Comlink.expose({
heavy: (data) => heavyComputation(data),
});
// main.ts
import * as Comlink from 'comlink';
const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
const api = Comlink.wrap(worker);
const result = await api.heavy(data); // looks just like a regular awaitLimitations
- No access to DOM/window (but
self,fetch,WebSocketare available) - Exchange via
postMessage— data is cloned, large transfers are expensive - Transferable Objects (
ArrayBuffer) — transferred without copying SharedArrayBufferrequires COOP/COEP headers
5.10. Debounce and Throttle in React — Hidden Pitfalls
// DOESN'T WORK as expected
function Search() {
const [value, setValue] = useState('');
const debounced = debounce((v) => fetch(v), 300);
// Every render = NEW debounce function, the internal timer is lost
return <input onChange={(e) => debounced(e.target.value)} />;
}
// Correct #1: useMemo
const debounced = useMemo(() => debounce(fetch, 300), []);
useEffect(() => debounced.cancel, [debounced]); // cancel on unmount
// Correct #2 (better): debounce the value, not the call
function Search() {
const [value, setValue] = useState('');
const debouncedValue = useDebouncedValue(value, 300);
useEffect(() => { if (debouncedValue) fetch(debouncedValue); }, [debouncedValue]);
return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}5.11. useTransition and useDeferredValue
When it helps: there's an urgent update (typing into an input) and expensive derived work (filtering 10k rows).
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const results = useMemo(
() => filterBig(items, deferredQuery),
[items, deferredQuery]
);
// Input stays responsive — the heavy list updates later and can be interruptedDifference:
useTransition— you explicitly mark an update as non-urgentuseDeferredValue— reader model: "react to the deferred version of this value"
5.12. Memory Leaks — Where the Leaks Hide
Timers and Subscriptions Without Cleanup
useEffect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id); // REQUIRED
}, []);
useEffect(() => {
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);WebSocket / EventSource
useEffect(() => {
const ws = new WebSocket(url);
ws.onmessage = handle;
return () => ws.close();
}, [url]);Closures Holding Large State
function makeHandler() {
const hugeArray = new Array(1e6).fill(0);
const small = hugeArray[0];
return () => small; // closed over the entire scope — hugeArray won't be freed
}Finding Leaks in DevTools
- Memory → Heap Snapshot
- Take Snapshot 1
- Repeat the suspicious action N times (open/close a modal)
- Snapshot 2 → Comparison view
- Look at #Delta — which objects increased abnormally
In Performance monitor, watch JS heap size — if it grows monotonically, that's a leak.
5.13. Layout Thrashing — Why the Browser Slows Down
Reflow — layout recalculation. Expensive. Repaint — redraw without layout. Cheaper.
Forced Synchronous Layout
// Bad — read after write → reflow on every iteration
for (const el of elements) {
el.style.width = el.offsetWidth + 10 + 'px';
}
// Good — all reads first, then all writes
const widths = elements.map(el => el.offsetWidth);
elements.forEach((el, i) => { el.style.width = widths[i] + 10 + 'px'; });Properties That Trigger Reflow on Read
offsetWidth/Height/Top/Left, clientWidth/Height, scrollWidth/Height/Top/Left, getBoundingClientRect(), getComputedStyle(), innerWidth/Height
Animations Without Reflow — Use the GPU
/* Bad — triggers reflow */
.moving { transition: left 0.3s; }
/* Good — compositing only */
.moving { transition: transform 0.3s; }
/* Hint to the browser: "I'll be animating transform" */
.moving { will-change: transform; }
/* Don't sprinkle will-change everywhere — it consumes GPU memory */5.14. Caching
HTTP
Cache-Control: max-age=31536000, immutable— for hashed files (app.abc123.js)Cache-Control: no-cache— always revalidate (typical forindex.html)ETag/Last-Modified— conditional requests (304 Not Modified)stale-while-revalidate— serve stale and quietly refresh in background
Service Worker and PWA
- Strategies: cache-first, network-first, stale-while-revalidate
- Workbox — convenient abstraction over all of this
In-Memory for API
- TanStack Query / SWR / RTK Query — request deduplication, invalidation, stale-while-revalidate
- Better than a manual Redux cache for server state