← Back to writing
Mar 202625 мин

Optimizing Performance in React: The Complete Guide [EN]

Everything you need to know about React performance—from profiling tools to virtualization, memoization, and Web Workers. With code examples and breakdowns of common errors.

ReactArchitecturePerformance
Article cover
🇷🇺 Русская версия (тема 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
javascript
// 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

  1. DevTools → Performance → Record
  2. Reproduce the problematic action → Stop
  3. Look for:

Bundle Analyzer

bash
# Vite
npm i -D rollup-plugin-visualizer

# Webpack
npm i -D webpack-bundle-analyzer

The 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 / useDeferredValue for 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/height on <img> (or aspect-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

javascript
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 prod

5.3. Causes of Unnecessary Re-renders — Breaking It Down

1. New reference in props of a React.memo component

javascript
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

javascript
// 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

javascript
// Every render = new array → effect fires every time
useEffect(() => { ... }, [filters]);

// Better: decompose to primitives
useEffect(() => { ... }, [filters.status, filters.search]);

5. Unstable keys in lists

javascript
// 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

javascript
// 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

javascript
// 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
javascript
// 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 them

Anti-patterns — what not to do

javascript
// 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 > savings

5.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
javascript
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: auto is required on the scroll container
  • position: absolute + transform for rows (faster than top/left)
  • If height is unknown — measureElement recalculates after render
  • For chats with reverse scroll — Virtuoso is more convenient

5.6. Code Splitting

By Route — the baseline

javascript
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

javascript
const MarkdownEditor = lazy(() => import('./MarkdownEditor'));

{showEditor && (
  <Suspense fallback={<EditorSkeleton />}>
    <MarkdownEditor />
  </Suspense>
)}

By Library — for rare operations

javascript
async function exportToPdf(data) {
  const { jsPDF } = await import('jspdf'); // loads only when "Export" is clicked
  const doc = new jsPDF();
  // ...
}

Preload on Hover

javascript
const Dashboard = lazy(() => import('./Dashboard'));

<Link
  to="/dashboard"
  onMouseEnter={() => {
    import('./Dashboard'); // browser starts fetching the chunk early
  }}
>
  Dashboard
</Link>

Handling ChunkLoadError

javascript
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

javascript
// 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

javascript
// 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

  1. CommonJS dependencies — bundler can't analyze statically
  2. Side effects in modules — need "sideEffects": false in package.json
  3. import * as X from 'mod' — named imports work better
  4. 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

html
<img src="photo.jpg" loading="lazy" decoding="async" width="800" height="600" />
<!-- width/height are required — otherwise CLS -->

srcset for Retina and Mobile

html
<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

html
<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
html
<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.

javascript
// 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

javascript
// 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 await

Limitations

  • No access to DOM/window (but self, fetch, WebSocket are available)
  • Exchange via postMessage — data is cloned, large transfers are expensive
  • Transferable Objects (ArrayBuffer) — transferred without copying
  • SharedArrayBuffer requires COOP/COEP headers

5.10. Debounce and Throttle in React — Hidden Pitfalls

javascript
// 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).

javascript
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 interrupted

Difference:

  • useTransition — you explicitly mark an update as non-urgent
  • useDeferredValue — reader model: "react to the deferred version of this value"

5.12. Memory Leaks — Where the Leaks Hide

Timers and Subscriptions Without Cleanup

javascript
useEffect(() => {
  const id = setInterval(tick, 1000);
  return () => clearInterval(id); // REQUIRED
}, []);

useEffect(() => {
  window.addEventListener('resize', handler);
  return () => window.removeEventListener('resize', handler);
}, []);

WebSocket / EventSource

javascript
useEffect(() => {
  const ws = new WebSocket(url);
  ws.onmessage = handle;
  return () => ws.close();
}, [url]);

Closures Holding Large State

javascript
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

  1. Memory → Heap Snapshot
  2. Take Snapshot 1
  3. Repeat the suspicious action N times (open/close a modal)
  4. Snapshot 2 → Comparison view
  5. 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

javascript
// 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

css
/* 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 for index.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
© 2026 Artem Prokopenko — Senior Front-End EngineerBack to top ↑