Back to writing
React · Architecture · PerformanceMar 2026·25 мин

Optimizing Performance in React: The Complete Guide

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.

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

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

Bundle Analyzer

# 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

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

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 them

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

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

  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

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

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

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

  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

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