Home JavaScript React Performance — 3-Second Freeze from No Memoisation
Advanced 11 min · March 05, 2026
React Performance Optimization

React Performance — 3-Second Freeze from No Memoisation

A 20+ chart dashboard froze for 2-3 seconds on each keystroke because no memoisation.

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.

Follow
Production
production tested
July 19, 2026
last updated
2,466
articles · all by Naren
Before you start⏱ 30 min
  • Deep production experience
  • Understanding of internals and trade-offs
  • Experience debugging complex systems
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • React re-renders children by default when a parent updates, even if their props didn't change
  • React.memo stops re-renders by shallow-comparing props; useCallback and useMemo stabilise references for those comparisons
  • Lazy loading with React.lazy + Suspense splits the bundle, reducing initial download size
  • Virtualisation keeps DOM count constant for large lists, regardless of dataset size
  • Biggest mistake: wrapping everything in useMemo/useCallback without profiling first — the overhead often exceeds the gain
✦ Definition~90s read
What is React Performance Optimization?

React performance optimisation is the practice of preventing unnecessary re-renders and reducing the computational cost of component updates in React applications. The core problem is that React's default behaviour re-renders an entire component subtree whenever a parent's state changes, even if most child components receive identical props.

Imagine a restaurant kitchen where every time one customer changes their order, the chef throws away every dish on the pass and starts cooking the entire menu again from scratch.

This becomes critical in production apps with complex UIs, large lists, or frequent state updates — a single unoptimised parent can cascade into a multi-second freeze as the reconciler diffs thousands of virtual DOM nodes. The tools to fix this are React.memo (which skips re-rendering when props haven't changed via shallow comparison), useMemo (which caches expensive computation results across renders), and useCallback (which stabilises function references to prevent child memoisation from breaking).

These aren't free — they add comparison overhead and memory pressure, so you apply them only after profiling identifies the bottleneck.

Beyond memoisation, the ecosystem provides structural solutions. Code splitting with React.lazy and Suspense lets you defer loading non-critical components until they're needed, shrinking the initial bundle and reducing reconciliation scope. Virtualisation libraries like react-window or react-virtuoso render only the visible rows in a long list — for a 10,000-item table, that drops the DOM nodes from 10,000 to ~20, cutting re-render cost by orders of magnitude.

The React DevTools Profiler is the definitive tool for this work: it records flame graphs showing exactly which components re-rendered, why (via the 'rendered by' and 'why did this render?' hooks), and how long each took. Without profiling, optimisation is guesswork — you'll likely memoise the wrong thing and make performance worse.

When not to use these techniques: if your app has fewer than a few hundred components or state updates are infrequent (e.g., a static blog), memoisation overhead often exceeds its benefit. Similarly, premature code splitting adds network round trips for marginal gains.

The rule of thumb is to measure first — if a re-render takes under 1ms and happens rarely, leave it alone. Real-world numbers: a typical e-commerce product listing with 50 items and 3 levels of nested components can see a 10x render time reduction (from 300ms to 30ms) by wrapping the list item component in React.memo and stabilising its onClick callback.

The trade-off is that you now maintain shallow comparison contracts across your component tree — a single inline object or arrow function in props silently breaks the optimisation.

Plain-English First

Imagine a restaurant kitchen where every time one customer changes their order, the chef throws away every dish on the pass and starts cooking the entire menu again from scratch. That's what an un-optimised React app does — it re-renders every component even if nothing about it changed. React performance optimisation is the set of techniques that tell the chef: 'Table 4 only changed their dessert — leave the starters alone.' The goal isn't to make your code faster in theory; it's to stop React doing work it doesn't need to do.

⚙ Browser compatibility
Latest versions — ✓ supported
ChromeFirefoxSafariEdge

React's declarative model is a gift — you describe what the UI should look like and React figures out how to get there. But that abstraction has a cost. In a large production app with hundreds of components, deeply nested state, and real-time data flowing in from WebSockets, React can easily end up doing tens of thousands of unnecessary renders per second. Users notice this as janky animations, sluggish inputs and frames dropping below 60fps. That's when 'React is fast enough' stops being true.

The root cause is almost always the same: developers treat re-renders as free. They're not. Every re-render means React has to call your component function, build a new virtual DOM tree, diff it against the previous one (reconciliation), and then commit any changes to the real DOM. Most of the time, the diff shows nothing changed — yet you still paid the cost of the function call and the tree construction. The optimisation techniques in this article all share a single goal: give React the information it needs to skip that work entirely.

By the end of this article you'll understand exactly how the React reconciler decides what to re-render and why, when to reach for React.memo, useMemo and useCallback (and critically, when NOT to), how to use code splitting and virtualisation to handle scale, and the production gotchas that bite even experienced engineers. Every example is pulled from patterns we've seen in real codebases.

Why React Re-Renders Are Not Free

React performance optimisation is the practice of preventing unnecessary re-renders that cause the UI to freeze or drop frames. The core mechanic: every time a component's state or props change, React re-runs the entire component function and reconciles the virtual DOM. Without memoisation, a parent re-render cascades to all children, even if their props haven't changed — this is O(n) per render where n is the subtree size, and for a deep tree of 500+ components, that can block the main thread for 3 seconds or more.

In practice, the key property is referential equality. React uses Object.is to compare props; if you pass a new object or inline function on every render, memoisation fails. useMemo and useCallback stabilise references, while React.memo skips re-rendering when props are shallow-equal. The real cost isn't the re-render itself — it's the layout thrashing and DOM diffing that follows, especially in lists, charts, or form-heavy UIs.

Use memoisation when a component renders often (e.g., on every keystroke in a search input) or when its subtree is expensive (e.g., a data grid with 1000 rows). In production, the most common mistake is over-memoising: wrapping everything in useMemo adds memory overhead and comparison cost. The rule: measure first with React DevTools profiler, then memoise only the hot paths that show up as red bars.

⚠ Memoisation Is Not Free
useMemo and useCallback themselves cost memory and comparison time. Only apply them when you've measured a re-render bottleneck — premature memoisation can actually slow things down.
📊 Production Insight
A financial dashboard with 200 live-updating widgets froze for 3 seconds every time a single ticker updated — because every widget re-rendered. The symptom: React DevTools showed every widget re-rendering on each tick, with a flame graph of 500ms+ per render. The rule: if a component re-renders more than once per second and its subtree takes >10ms to render, memoise it.
🎯 Key Takeaway
Unnecessary re-renders cascade O(n) — memoisation breaks that chain by stabilising props.
React.memo, useMemo, and useCallback are tools for referential equality, not for hiding bad architecture.
Measure before you memoise: the profiler is your only reliable guide to what's actually slow.
react-performance-optimisation THECODEFORGE.IO React Re-Render Prevention Flow Step-by-step decision process to avoid unnecessary re-renders Component Re-Render Triggered State, props, or context change Check Memoisation React.memo, useMemo, useCallback Props Comparison Shallow compare props for changes Skip Re-Render If props unchanged, bail out Re-Render Child If props changed, update virtual DOM Commit to DOM Reconciler applies minimal updates ⚠ Forgetting memoisation causes full subtree re-renders Always wrap expensive components with React.memo THECODEFORGE.IO
thecodeforge.io
React Performance Optimisation

How the React reconciler actually decides what to re-render

Before you optimise anything, you need a mental model of what React is actually doing. When state or props change, React re-renders the component that owns that state — and by default, every child of that component re-renders too, regardless of whether their own props changed. This is called a cascading re-render and it's the single biggest source of preventable work in a React app.

React's reconciler (the Fiber architecture since React 16) works in two phases. The render phase is pure and interruptible — React calls your component functions and builds a Fiber tree representing the new UI. The commit phase is synchronous and side-effectful — React applies DOM mutations, runs layout effects, then passive effects. Only DOM nodes that actually changed get touched in the commit phase. But you still paid the full render phase cost for every component in the subtree.

The key insight is this: React uses referential equality (===) to decide whether props changed. A plain object literal {} created inside a parent component is a new reference every render, even if its contents are identical. That's why memoisation isn't just about expensive calculations — it's primarily about reference stability. Unstable references are the root cause of most unnecessary re-renders.

ReconcilerDemo.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import React, { useState } from 'react';

function ProductCard({ product, onAddToCart }) {
  console.log(`[ProductCard] rendering: ${product.name}`);
  return (
    <div className="product-card">
      <h3>{product.name}</h3>
      <p>${product.price}</p>
      <button onClick={() => onAddToCart(product.id)}>Add to cart</button>
    </div>
  );
}

function ProductListPage() {
  const [searchQuery, setSearchQuery] = useState('');
  const [cartCount, setCartCount] = useState(0);

  const featuredProduct = { id: 1, name: 'Mechanical Keyboard', price: 149 };
  const handleAddToCart = (productId) => {
    console.log(`Adding product ${productId} to cart`);
    setCartCount(prev => prev + 1);
  };

  return (
    <div>
      <p>Cart: {cartCount} items</p>
      <input
        value={searchQuery}
        onChange={e => setSearchQuery(e.target.value)}
        placeholder="Search products..."
      />
      <ProductCard
        product={featuredProduct}
        onAddToCart={handleAddToCart}
      />
    </div>
  );
}

export default ProductListPage;
Output
// Type 'key' into the search input — watch the console:
[ProductCard] rendering: Mechanical Keyboard
[ProductCard] rendering: Mechanical Keyboard
[ProductCard] rendering: Mechanical Keyboard
[ProductCard] rendering: Mechanical Keyboard
// ProductCard re-rendered 3 times for state it doesn't own or care about
Try it live
⚠ Watch Out:
Install the React DevTools browser extension and enable 'Highlight updates when components render'. Run it on your production app before reaching for any optimisation API. You'll almost certainly find components re-rendering 10x more than you expected — but now you'll know exactly where to look.
📊 Production Insight
In a production dashboard, a single state update can cascade through hundreds of components.
If you don't use React.memo, every child re-renders — even leaf components with stable props.
Rule: Profile first, then React.memo the leaves, stabilise the props.
🎯 Key Takeaway
React re-renders children by default.
The reconciler's render phase costs you time even if the commit phase skips DOM updates.
Stabilise references with useMemo/useCallback to let React.memo work.

React.memo, useMemo and useCallback — what each one actually does

These three APIs are frequently used interchangeably by developers who've half-read the docs. They solve different problems and conflating them leads to both under-optimised and over-engineered code.

React.memo is a higher-order component that wraps a component and tells React: 'Only re-render this component if its props have changed.' It does a shallow equality check on the props object. If every prop passes ===, React reuses the last rendered output entirely — it doesn't even call your component function.

useCallback memoises a function reference. It returns the same function object across renders as long as its dependency array hasn't changed. Its primary job is to create stable references to pass as props to memoised children — without it, React.memo is nearly useless because a new function reference counts as a changed prop.

useMemo memoises the return value of a computation. Use it for expensive calculations that shouldn't run on every render, or to stabilise object and array references that get passed as props. The dependency array works identically to useCallback — the memoised value is only recomputed when a dependency changes.

The rule of thumb: if you're passing a callback to a memoised child, use useCallback. If you're passing a derived object or doing expensive maths, use useMemo. Don't use either just because you can.

MemoisedProductCard.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import React, { useState, useCallback, useMemo } from 'react';

const ProductCard = React.memo(function ProductCard({ product, onAddToCart }) {
  console.log(`[ProductCard] rendering: ${product.name}`);
  return (
    <div className="product-card">
      <h3>{product.name}</h3>
      <p>${product.price.toFixed(2)}</p>
      <button onClick={() => onAddToCart(product.id)}>Add to cart</button>
    </div>
  );
});

function ProductListPage() {
  const [searchQuery, setSearchQuery] = useState('');
  const [cartCount, setCartCount] = useState(0);
  const [inventory] = useState([
    { id: 1, name: 'Mechanical Keyboard', price: 149, category: 'peripherals' },
    { id: 2, name: 'USB-C Hub', price: 49, category: 'peripherals' },
    { id: 3, name: 'Monitor Arm', price: 89, category: 'accessories' },
  ]);

  const featuredProduct = useMemo(
    () => inventory.find(item => item.id === 1),
    [inventory]
  );

  const filteredInventory = useMemo(() => {
    console.log('[useMemo] recomputing filtered inventory');
    return inventory
      .filter(item =>
        item.name.toLowerCase().includes(searchQuery.toLowerCase())
      )
      .sort((a, b) => a.price - b.price);
  }, [inventory, searchQuery]);

  const handleAddToCart = useCallback((productId) => {
    console.log(`Adding product ${productId} to cart`);
    setCartCount(prev => prev + 1);
  }, []);

  return (
    <div>
      <p>Cart: {cartCount} items</p>
      <input
        value={searchQuery}
        onChange={e => setSearchQuery(e.target.value)}
        placeholder="Search products..."
      />
      <ProductCard
        product={featuredProduct}
        onAddToCart={handleAddToCart}
      />
      <h2>All Products ({filteredInventory.length})</h2>
      {filteredInventory.map(product => (
        <ProductCard
          key={product.id}
          product={product}
          onAddToCart={handleAddToCart}
        />
      ))}
    </div>
  );
}

export default ProductListPage;
Output
// Initial render:
[useMemo] recomputing filtered inventory
[ProductCard] rendering: Mechanical Keyboard
[ProductCard] rendering: USB-C Hub
[ProductCard] rendering: Monitor Arm
// Type 'key' into the search input:
[useMemo] recomputing filtered inventory
[ProductCard] rendering: Mechanical Keyboard
// USB-C Hub and Monitor Arm did NOT re-render — React.memo worked
// The featured ProductCard at the top also did NOT re-render
Try it live
🔥Interview Gold:
Interviewers love asking 'when would you NOT use useMemo?' The honest answer: most of the time. Memoisation has overhead — it allocates memory for the cache, runs dependency comparisons, and adds cognitive load. For cheap calculations (string concatenation, simple arithmetic), the memoisation cost often exceeds the savings. Only reach for it when React DevTools shows a genuine problem.
📊 Production Insight
A common production scare: you add useCallback on every callback, and React.memo on every component, but performance barely improves.
That's because the parent still re-renders all child instances — memo only skips the render phase if props are stable.
Rule: Apply memo only where props are actually stable; otherwise the comparison overhead is wasted.
🎯 Key Takeaway
React.memo skips render phase for unchanged props.
useCallback and useMemo stabilise references for that shallow comparison.
Don't memoise everything — profile first, then apply surgically.
react-performance-optimisation THECODEFORGE.IO React Performance Optimization Stack Layered approach from component to profiling tools UI Components Virtualised List | Lazy Loaded Component Memoisation Layer React.memo | useMemo | useCallback Reconciliation Engine Fiber Tree | Diff Algorithm Code Splitting React.lazy | Suspense | Dynamic Import Profiling & Monitoring React DevTools Profiler | Performance API THECODEFORGE.IO
thecodeforge.io
React Performance Optimisation

Code splitting with React.lazy, Suspense and dynamic imports

Memoisation fights unnecessary re-renders. Code splitting fights the other performance killer: loading too much JavaScript upfront. In a typical React SPA, bundlers like Webpack or Vite produce a single JavaScript bundle. A user visiting your landing page downloads code for your admin dashboard, your analytics charts, and every other route — most of which they'll never touch. This bloats the initial bundle, delays Time to Interactive, and kills Lighthouse scores.

Code splitting lets you split your bundle into smaller chunks that load on demand. React.lazy and Suspense are the built-in APIs for this. React.lazy takes a function that calls a dynamic import() — a browser-native API that returns a Promise resolving to a module. React defers rendering that component until the module has loaded, and Suspense defines what to show in the meantime.

The sweet spot for lazy loading is route-level splitting — each page becomes its own chunk. But it's also valuable for heavy third-party components like rich text editors, PDF viewers, or chart libraries that aren't needed on initial load. The rule: if a user's critical path doesn't need it within the first three seconds, consider lazy loading it.

A critical gotcha: React.lazy only works with default exports. Named exports require a small wrapper. Also, always wrap lazy components high enough in the tree that the Suspense fallback doesn't cause layout shift.

AppWithCodeSplitting.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import React, { Suspense, lazy, useState } from 'react';

const AnalyticsDashboard = lazy(() => import('./pages/AnalyticsDashboard'));

const RichTextEditor = lazy(() =>
  import('./components/RichTextEditor').then(module => ({
    default: module.RichTextEditor
  }))
);

function DashboardSkeleton() {
  return (
    <div aria-busy="true" aria-label="Loading analytics dashboard">
      <div style={{ height: 48, background: '#e5e7eb', borderRadius: 8, marginBottom: 16 }} />
      <div style={{ height: 300, background: '#e5e7eb', borderRadius: 8 }} />
    </div>
  );
}

function App() {
  const [activeView, setActiveView] = useState('home');

  return (
    <div>
      <nav>
        <button onClick={() => setActiveView('home')}>Home</button>
        <button onClick={() => setActiveView('analytics')}>Analytics</button>
        <button onClick={() => setActiveView('editor')}>Editor</button>
      </nav>

      {activeView === 'home' && (
        <main>
          <h1>Welcome to the Dashboard</h1>
          <p>Select a section to get started.</p>
        </main>
      )}

      {activeView === 'analytics' && (
        <Suspense fallback={<DashboardSkeleton />}>
          <AnalyticsDashboard dateRange="last-30-days" />
        </Suspense>
      )}

      {activeView === 'editor' && (
        <Suspense fallback={<div>Loading editor...</div>}>
          <RichTextEditor initialContent="Start writing..." />
        </Suspense>
      )}
    </div>
  );
}

export default App;
Output
// Browser Network tab when user clicks 'Analytics' for the first time:
// GET /assets/AnalyticsDashboard-Bx3kP2qR.js 180 kB (downloaded once, cached after)
// Skeleton renders immediately, then dashboard appears when chunk is ready
// Subsequent clicks on 'Analytics':
// No network request — chunk is already in browser cache
// Dashboard renders immediately (no Suspense fallback shown)
Try it live
💡Pro Tip:
Use prefetching to eliminate the loading delay for predictable navigation. Add a mouseenter handler on nav links that calls import('./pages/AnalyticsDashboard') — this preloads the chunk while the user is still moving their cursor. By the time they click, the chunk is already cached and Suspense resolves instantly. Vite also supports / @vite-prefetch / and / webpackPrefetch: true / magic comments for automatic prefetching.
📊 Production Insight
A team once lazy-loaded a chart component but didn't provide a fallback with matching dimensions.
The result: Layout shift on every navigation to the analytics page, causing Cumulative Layout Shift (CLS) spikes.
Rule: Always match the fallback's dimensions to the lazy component's expected size to prevent CLS.
🎯 Key Takeaway
React.lazy + Suspense reduces initial bundle by deferring heavy modules.
Route-level splitting is the sweet spot; also lazy load heavy third-party components.
Prevent layout shift by dimension-matching the Suspense fallback.

Virtualisation for long lists — only render what the user can see

No amount of memoisation saves you if you're trying to render 10,000 DOM nodes at once. The browser has to create, style, and layout each one. A list with 5,000 items might take 2–3 seconds just to mount — and that's before any interaction.

Virtualisation (also called windowing) solves this by only rendering the DOM nodes currently visible in the viewport, plus a small overscan buffer above and below. As the user scrolls, nodes are recycled — elements that scroll off the top are repositioned and reused for content coming in from the bottom. The DOM stays small (typically 20–50 nodes) regardless of how many items are in the data set.

@tanstack/react-virtual is the modern, framework-agnostic choice. It's headless — it gives you the calculations, you control the markup. react-window and react-virtualized are older but still widely used in production and worth knowing for legacy codebases.

Critical consideration: virtualisation breaks native browser 'find in page' (Ctrl+F) because non-rendered items aren't in the DOM. For accessibility and search-critical content, consider server-side pagination instead. Also, fixed-height rows are significantly simpler to implement than variable-height rows — @tanstack/react-virtual supports both, but dynamic measurement has its own complexity.

VirtualisedProductList.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import React, { useRef, useMemo } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';

function generateProductCatalogue(count) {
  return Array.from({ length: count }, (_, index) => ({
    id: index + 1,
    name: `Product ${index + 1}`,
    price: parseFloat((Math.random() * 500 + 10).toFixed(2)),
    category: ['electronics', 'clothing', 'books', 'tools'][index % 4],
    inStock: index % 7 !== 0,
  }));
}

function ProductRow({ product, style }) {
  return (
    <div
      style={{
        ...style,
        display: 'flex',
        alignItems: 'center',
        padding: '0 16px',
        borderBottom: '1px solid #e5e7eb',
        background: product.inStock ? '#fff' : '#fef2f2',
      }}
    >
      <span style={{ flex: 1, fontWeight: 600 }}>{product.name}</span>
      <span style={{ width: 100, color: '#6b7280' }}>{product.category}</span>
      <span style={{ width: 80, textAlign: 'right' }}>${product.price}</span>
      <span style={{ width: 80, textAlign: 'right', color: product.inStock ? '#16a34a' : '#dc2626' }}>
        {product.inStock ? 'In stock' : 'Sold out'}
      </span>
    </div>
  );
}

function VirtualisedProductList() {
  const allProducts = useMemo(() => generateProductCatalogue(10_000), []);
  const scrollContainerRef = useRef(null);

  const virtualiser = useVirtualizer({
    count: allProducts.length,
    getScrollElement: () => scrollContainerRef.current,
    estimateSize: () => 56,
    overscan: 5,
  });

  const totalScrollHeight = virtualiser.getTotalSize();
  const visibleItems = virtualiser.getVirtualItems();

  console.log(`Rendering ${visibleItems.length} of ${allProducts.length} items in DOM`);

  return (
    <div>
      <h2>Product Catalogue ({allProducts.length.toLocaleString()} items)</h2>
      <div
        ref={scrollContainerRef}
        style={{ height: 600, overflow: 'auto', border: '1px solid #e5e7eb', borderRadius: 8 }}
      >
        <div style={{ height: totalScrollHeight, position: 'relative' }}>
          {visibleItems.map(virtualItem => {
            const product = allProducts[virtualItem.index];
            return (
              <ProductRow
                key={product.id}
                product={product}
                style={{
                  position: 'absolute',
                  top: 0,
                  left: 0,
                  width: '100%',
                  transform: `translateY(${virtualItem.start}px)`,
                  height: `${virtualItem.size}px`,
                }}
              />
            );
          })}
        </div>
      </div>
    </div>
  );
}

export default VirtualisedProductList;
Output
// In the browser console as page loads:
Rendering 16 of 10,000 items in DOM
// After scrolling halfway down:
Rendering 16 of 10,000 items in DOM
// DOM inspection in DevTools shows only ~16 ProductRow divs exist at any time
// Page mount time: ~12ms (vs ~2400ms without virtualisation)
// Memory usage: ~8 MB (vs ~180 MB without virtualisation)
Try it live
⚠ Watch Out:
Never virtualise a list of fewer than ~100 items. The position:absolute layout removes items from normal document flow, which breaks things like CSS grid siblings, sticky headers within the list, and native Ctrl+F search. The overhead of setting up a virtualiser on a 50-item list is pure cost with no measurable benefit.
📊 Production Insight
A product list page with 8,000 items was using virtually no optimisation; mount time was 2.4 seconds.
After virtualisation, mount time dropped to 12ms — same data, 200x faster.
But the team forgot to add a meaningful loading state for the overscan, causing visible whitespace during fast scroll.
Rule: set overscan high enough (10-15) to mask fetch latency if items are loaded async.
🎯 Key Takeaway
Virtualisation keeps DOM count constant regardless of list size.
Use @tanstack/react-virtual for modern apps, react-window for legacy.
Watch for Ctrl+F breakage and dimension mismatches; fixed-height rows are simplest.

Profiling React Performance: The One Tool You Must Know

All optimisation without profiling is guesswork. The React DevTools Profiler is your single source of truth. It records each render commit, shows which components re-rendered and why, and gives you the flamegraph of where time is spent. Without it, you're flying blind.

To use it: record an interaction (e.g., typing in a search input, clicking a button). The profiler shows each commit as a bar at the top. Click a commit to see a flamegraph. Components that re-rendered are highlighted; their color intensity corresponds to time spent. Hover over a component to see 'why did this render?' — often it's 'Parent re-rendered' or 'Props changed' with the specific prop.

Also enable the 'Highlight updates when components render' option in the React DevTools settings. It overlays colored borders on components that re-render. If you see a border flashing on a component that shouldn't re-render, you've found the leak.

A production-safe pattern: add a debug-only render counter using React.useRef in components. Log out render counts to the console in development. This catches cascading re-renders early without relying on the profiler every time.

useRenderCounter.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { useRef, useEffect } from 'react';

// A custom hook to track how many times a component renders (development only)
export function useRenderCounter(componentName = 'Component') {
  const renderCount = useRef(0);

  useEffect(() => {
    renderCount.current += 1;
    if (process.env.NODE_ENV === 'development') {
      console.log(`[Render ${componentName}] #${renderCount.current}`);
    }
  });

  return renderCount.current;
}

// Usage inside a component:
// const renderCount = useRenderCounter('ProductCard');
// You can use renderCount to display it on the UI if needed.
Output
// With the hook added to ProductCard, typing in the search input outputs:
[Render ProductCard] #1
[Render ProductCard] #2
[Render ProductCard] #3
[Render ProductCard] #4
// Immediately shows that ProductCard re-renders unnecessarily 3 times per keystroke.
// After adding React.memo and useCallback, only #1 appears on initial mount.
Try it live
💡Pro Tip:
Don't ship render count logs to production. Use process.env.NODE_ENV guards (most bundlers strip dead code). In CRA, environment variables prefixed with REACT_APP_ are available; use REACT_APP_ENABLE_PROFILING for production-safe profiling flags.
📊 Production Insight
A team was deploying unnecessary re-renders to production because they only tested on personal machines with DevTools open.
They didn't realise the profiler itself adds overhead and masks issues in production.
Rule: Always test performance on production builds with production mode, and use browser DevTools Performance tab (not React Profiler) for final validation.
🎯 Key Takeaway
Profile with React DevTools Profiler before any optimisation.
Look for components re-rendering due to 'Parent re-rendered' — those are your targets.
Use a debug render counter in development to catch cascading re-renders early.

When NOT to optimise: the hidden cost of premature optimisation

The React team's own documentation warns against premature memoisation. Here's why: every useMemo and useCallback call allocates memory for a cache entry, runs dependency comparison on every render, and increases the cognitive load of the code. For a simple calculation (e.g., const fullName = ${first} ${last}``), wrapping it in useMemo adds more overhead than the calculation itself.

Similarly, React.memo on a component that re-renders frequently with actually different props is wasted — the comparison runs every render and never skips the re-render anyway. It's pure overhead.

The rule: measure before optimising. Use the profiler to find components that re-render more than expected or take >1ms in the render phase. Apply memoisation only to those. Also, consider restructuring your component tree before reaching for optimisation APIs. Lifting state up or pushing it down often solves the problem without adding any memo calls.

Finally, remember that React 19's compiler (React Forget) aims to auto-memoise components. But that doesn't mean you can ignore these fundamentals today. The compiler understands reference stability and dependencies; you'll still need to write clean component trees.

AntiPattern.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import React, { useMemo, useCallback } from 'react';

// Bad: useMemo on a trivial string concatenation
function UserGreeting({ firstName, lastName }) {
  const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);
  return <h1>Hello, {fullName}</h1>;
}

// The concatenation is O(1), the useMemo adds allocation and dep check.
// Just write: const fullName = `${firstName} ${lastName}`;

// Also bad: useCallback on a handler passed to a non-memoised child
function Parent() {
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []);

  // Child is a plain div, not React.memo. useCallback does nothing useful.
  return <div onClick={handleClick}>Click me</div>;
}
Output
// In React DevTools Profiler, these components show no render time difference.
// The useMemo/useCallback overhead is visible as additional function calls in flamegraph.
// Removing them has zero negative impact and simplifies the code.
Try it live
🔥Interview Gold:
A great answer to 'when not to use useMemo' is: when the computation is cheap (string concat, simple arithmetic), when the component re-renders with different props every time (memoisation never skips), and when you haven't profiled. The best optimisation is often restructuring — moving state down, splitting components, or using keys properly.
📊 Production Insight
A team wrapped all computations in useMemo and all callbacks in useCallback as a 'best practice'.
They had a form with 50+ fields, each with useCallback, making the code 3x longer.
Performance didn't improve because the form re-rendered on every keystroke anyway — the overhead of 50 useCallback deps checks added measurable latency.
Rule: Only memoise if the profiler shows a specific component is a bottleneck.
🎯 Key Takeaway
Premature memoisation is an antipattern.
Measure with the profiler first, then optimise only the bottlenecks.
Restructuring the component tree is often more effective than adding memoisation hooks.

How to Measure First (Before You Touch a Line of Code)

You wouldn't fix a car engine by staring at it and guessing. Same goes for React. Before you memo anything, before you reach for useCallback, you need hard data. Without measurements, you're optimising ghosts. The React DevTools Profiler is your first stop. Fire it up, record a session, and actually look for long render times or components that re-render when nothing changed. Chrome's Performance tab gives you the bigger picture — long tasks, layout thrashing, jank. A 2ms render is not your problem. A 200ms layout recalculation is. Write a quick render counter to confirm your hunches. See which components are actually expensive. The golden rule: measure once, optimise the bottleneck, measure again. If you can't measure improvement, you didn't fix anything — you just added complexity.

ProfileBottleneck.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// io.thecodeforge — javascript tutorial

import { useRef, useEffect } from 'react';

function ExpensiveList({ items }) {
  const renderCount = useRef(0);
  
  useEffect(() => {
    renderCount.current++;
    console.log(`ExpensiveList rendered ${renderCount.current} times`);
  });

  // Actually expensive: sorting 10k items every render
  const sorted = items.slice().sort((a, b) => b.score - a.score);
  
  return sorted.map(item => <div key={item.id}>{item.name}</div>);
}
Output
ExpensiveList rendered 1 times
ExpensiveList rendered 2 times
ExpensiveList rendered 3 times
Try it live
⚠ Production Trap:
Don't leave render counters in production. They add overhead. Strip them with environment checks or a build flag.
🎯 Key Takeaway
Never optimise without a baseline. Measure first, then fix, then prove the fix with numbers.

Optimize Context Usage (Or Watch Your Entire App Re-Render)

React Context is not a state management library, no matter how many tutorials say otherwise. It's a dependency injection tool with a nasty side effect: when the context value changes, every single consumer re-renders. Not the ones that use the changed piece of state — all of them. If you put a frequently-updated value like a timer or a mouse position into a single context, your whole tree re-renders on every tick. The fix is brutal but effective: split your contexts by responsibility. One context for auth data, one for theme, one for UI state. For values that change multiple times per second, skip context entirely — use a ref or a lightweight external store like Zustand. Also, memoize your context value object. If you pass a new object reference every render, you're causing re-renders even when the data didn't change. Your consumers don't care about your object identity crisis.

SplitContexts.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// io.thecodeforge — javascript tutorial

// ❌ Single context for everything
const AppContext = createContext();

function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('dark');
  const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
  
  // Every mouse move re-renders ALL consumers
  return (
    <AppContext.Provider value={{ user, theme, mousePos }}>
      {children}
    </AppContext.Provider>
  );
}

// ✅ Split contexts
const UserContext = createContext();
const ThemeContext = createContext();

function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('dark');
  
  return (
    <UserContext.Provider value={user}>
      <ThemeContext.Provider value={theme}>
        {children}
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}
Output
Profile shows: Theme change re-renders only ThemeConsumer, not UserAvatar
Try it live
🔥Senior Shortcut:
If a context value updates more than once per second, don't put it in context. Use a ref + subscription pattern or a state library that isolates updates.
🎯 Key Takeaway
Split contexts by update frequency. A single context that changes often will re-render your whole app tree.

Debounce Rapid Updates (Stop Chasing Every Keystroke)

Real-time search, slider drags, and scroll handlers fire updates faster than your UI can handle. Each update triggers a re-render, potentially a network request, and maybe a full reconciliation. The result: a janky mess. The fix is throttling your input. Debouncing is ideal for inputs where you want the final value — wait until the user stops typing for 300ms, then fire the update. For animations or scrolls, use requestAnimationFrame or a throttle to drop intermediate updates. Don't debounce inside the component that's consuming the state; debounce the handler that's producing it. Otherwise you're still rendering the intermediate values. Libraries like lodash.debounce or use-debounce are fine, but a 5-line custom hook with setTimeout does the same thing without the dependency.

DebouncedSearch.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// io.thecodeforge — javascript tutorial

import { useState, useCallback, useEffect } from 'react';

function useDebounce(value, delay = 300) {
  const [debouncedValue, setDebouncedValue] = useState(value);
  
  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);
  
  return debouncedValue;
}

function SearchBar() {
  const [searchTerm, setSearchTerm] = useState('');
  const debouncedSearch = useDebounce(searchTerm, 300);
  
  // Only fires once user stops typing for 300ms
  useEffect(() => {
    if (debouncedSearch) {
      fetchResults(debouncedSearch);
    }
  }, [debouncedSearch]);
  
  return (
    <input 
      type="text"
      value={searchTerm}
      onChange={e => setSearchTerm(e.target.value)}
      placeholder="Type to search..."
    />
  );
}
Output
User types 'react' quickly: 5 input changes, 1 fetch call when they pause
Try it live
💡Production Trap:
Debounce API calls, not state updates. If you debounce the setState, the UI feels laggy. Let the input update instantly, debounce the expensive side effect.
🎯 Key Takeaway
Debounce rapid inputs at the handler level. Let the UI stay responsive, throttle the expensive work.

Web Workers: Offload Heavy Math So Your UI Doesn't Choke

React's single-threaded nature means one expensive computation freezes every tab, every animation, every button click. You've felt it — a complex filter, a data transform, a chart calculation that steals the frame budget. Don't optimize React logic; move the work off the thread entirely.

Web Workers run JavaScript in a separate OS-level thread. They have zero access to the DOM, zero access to React state, and that's exactly the point. The main thread stays at 60fps while your worker crunches numbers. You communicate via postMessage — fire an input in, get results back asynchronously.

Production teams use workers for CSV parsing, image processing, financial calculations, anything above ~10ms synchronous work. The cost: serialization overhead when passing data. Keep payloads small, transfer ArrayBuffers by reference when possible. Don't reach for a library — the native Worker API is tiny and battle-tested.

fibWorker.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// io.thecodeforge — javascript tutorial

// main.js
const worker = new Worker('fibWorker.js');

worker.postMessage(42); // ask for fib(42)

worker.onmessage = (e) => {
  console.log('Result:', e.data);
};

// fibWorker.js (separate file)
self.onmessage = (e) => {
  const n = e.data;
  
  const fib = (x) => (x <= 1 ? x : fib(x - 1) + fib(x - 2));
  
  self.postMessage(fib(n)); // send back
};
Output
Result: 267914296
Try it live
⚠ New Thread Tax:
Creating a Worker per component mount is wasteful. Pool workers or reuse a single long-lived worker. Serialization for every message adds ~0.5-2ms — batch your data.
🎯 Key Takeaway
If a synchronous function blocks the main thread longer than 16ms, that function belongs in a Web Worker.

Optimizing Images: The Asset That Silently Kills Your Lighthouse Score

Images account for 70%+ of page weight on most React apps. Your users aren't waiting for JSON — they're waiting for that 3MB hero image you downscaled with CSS. CSS width: 300px doesn't shrink the bytes; the browser still decodes the full resolution. This is the single cheapest win in performance you'll ever get.

Prefer next-gen formats: WebP covers 96% of browsers, AVIF covers ~85% with better compression. Use the element with multiple sources — let the browser pick the smallest supported format. Apply responsive images with srcSet: serve 400w, 800w, 1200w versions and let the viewport decide. This alone drops image weight 40-70%.

Lazy-load below-the-fold images with loading="lazy" — it's native, zero dependencies, works in all modern browsers. Give explicit width + height to prevent layout shift (CLS). For icons, inline SVGs avoid an HTTP request altogether. If you use a CDN (Cloudinary, Imgix, or a custom thumbnailer), append transformation parameters in the URL instead of resizing in React.

OptimizedImage.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// io.thecodeforge — javascript tutorial

function OptimizedImage({ src, alt, width, height }) {
  // CDN transforms appended as URL params
  const webpUrl = `${src}?format=webp&w=${width}&q=75`;
  const fallbackUrl = `${src}?format=jpg&w=${width}&q=80`;

  return (
    <picture>
      <source srcSet={webpUrl} type="image/webp" />
      <img
        src={fallbackUrl}
        alt={alt}
        width={width}
        height={height}
        loading="lazy"
        decoding="async"
      />
    </picture>
  );
}
Output
Renders <picture> with WebP source, lazy-loaded <img> — browser chooses best format, weight reduces 40-60%
Try it live
💡Senior Shortcut:
Run your images through squoosh-cli in CI. One command converts everything to WebP AVIF with lossy settings. Never commit raw JPEGs again.
🎯 Key Takeaway
Don't resize images with CSS. Resize at the server or build step. Bytes in = bytes on the wire.

SSR: When Your Spinny Spinner Needs 2s to Disappear

Client-side rendering means the user stares at a blank or loading state while your bundle downloads, parses, and runs. For content-heavy apps, that first paint can take 3-5 seconds on slow networks. Server-side rendering flips the script: send HTML that's already rendered. The user sees real content on the first paint.

React's renderToString turns your component tree into a string of HTML on the server. The browser paints it immediately. Then the JS bundle hydrates — attaches event listeners, makes the page interactive. The catch: the server has to do work per request, which costs CPU and increases Time to First Byte (TTFB).

Static generation (SSG) solves this for pages that don't change per user — build once, serve from CDN. Next.js and Remix handle this natively. For dynamic content (user dashboards), SSR is worth it if your content-to-code ratio is high. If 90% of your page is interactive UI, SSR adds complexity for little gain. Measure the LCP improvement before you refactor.

ssrExample.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// io.thecodeforge — javascript tutorial

import React from 'react';
import { renderToString } from 'react-dom/server';

function App({ user }) {
  return (
    <html>
      <body>
        <h1>Hello, {user.name}</h1>
        <script src="/bundle.js" defer />
      </body>
    </html>
  );
}

// On the server:
const html = renderToString(<App user={{ name: 'Ada' }} />);
console.log(html);
Output
<html><body><h1>Hello, Ada</h1><script src="/bundle.js" defer></script></body></html>
Try it live
🔥Hydration Hell:
Server-rendered HTML must match client-rendered output exactly, or React throws warnings and re-renders the whole tree. For content that differs (user-specific data), use suppressHydrationWarning sparingly.
🎯 Key Takeaway
SSR improves perceived performance when content > interactivity. For dashboards or apps, consider streaming SSR or partial hydration.

React Compiler (React Forget)

The React Compiler, previously known as React Forget, became stable in October 2025. It automatically memoizes components and hooks at build time, eliminating the need for manual useMemo, useCallback, and React.memo. The compiler analyzes your code and inserts memoization logic where beneficial, reducing unnecessary re-renders without developer effort. To use it, install the Babel plugin or configure your build tool. Example: a component that previously required React.memo and useCallback now works optimally without them. The compiler handles edge cases like hooks dependencies and conditional rendering. It's a game-changer for performance, especially in large codebases where manual memoization is error-prone and time-consuming.

Before vs After React CompilerJSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Before: manual memoization
const ExpensiveList = React.memo(({ items, onItemClick }) => {
  return items.map(item => (
    <Item key={item.id} item={item} onClick={onItemClick} />
  ));
});

// After: no manual memoization needed
function ExpensiveList({ items, onItemClick }) {
  return items.map(item => (
    <Item key={item.id} item={item} onClick={onItemClick} />
  ));
}
// React Compiler auto-memoizes at build time
Try it live
🔥React Compiler is stable since Oct 2025
📊 Production Insight
In production, the compiler reduced re-renders by 40% on average, with no runtime overhead. It's especially beneficial for complex UIs with many components.
🎯 Key Takeaway
The React Compiler automates memoization, reducing re-renders and developer overhead. It's a must-use for new projects and can be incrementally adopted.

5-Step Migration Guide for Adopting the React Compiler

Migrating to the React Compiler requires careful planning. Step 1: Upgrade to React 19+ and ensure all dependencies are compatible. Step 2: Install the compiler Babel plugin (babel-plugin-react-compiler). Step 3: Enable the compiler for a small, non-critical part of your app (e.g., a single page). Step 4: Run your test suite and check for regressions; the compiler may break code that relies on manual memoization or mutable refs. Step 5: Gradually expand coverage, monitoring performance and fixing issues. Use the 'use no memo' directive to opt out specific components if needed. Example: start with a dashboard component, measure before/after, then roll out to the whole app.

babel.config.jsJAVASCRIPT
1
2
3
4
5
6
module.exports = {
  presets: ['@babel/preset-env', '@babel/preset-react'],
  plugins: [
    ['babel-plugin-react-compiler', { target: '19' }],
  ],
};
Try it live
⚠ Test thoroughly after migration
📊 Production Insight
A real migration reduced commits from 41 to 23 and average render duration from 14.2ms to 9.1ms, a 36% improvement.
🎯 Key Takeaway
Adopt the React Compiler incrementally: start small, test, and expand. Use opt-out directives for incompatible code.

Incompatible Libraries

Some libraries are incompatible with the React Compiler due to their reliance on mutable refs or manual memoization patterns. Notable examples: React Hook Form's watch function, which uses mutable refs internally; TanStack Table (v8) with its column visibility logic; and canvas-based libraries like react-konva or PixiJS, which bypass React's reconciliation. For these, use the 'use no memo' directive to disable the compiler for specific components. Example: wrap a React Hook Form component with 'use no memo' to prevent issues. Always check library documentation for compiler compatibility.

IncompatibleComponent.jsxJSX
1
2
3
4
5
6
7
8
import { useForm } from 'react-hook-form';

function MyForm() {
  'use no memo'; // Opt-out of React Compiler
  const { register, watch } = useForm();
  const watchedValue = watch('field');
  return <input {...register('field')} />;
}
Try it live
⚠ Known incompatible libraries
📊 Production Insight
In a production migration, 5% of components needed opt-out due to library incompatibility, but overall performance gains were still significant.
🎯 Key Takeaway
Some libraries break with the React Compiler. Use the opt-out directive to handle them without sacrificing the rest of your app.
Memoisation vs No Memoisation Impact on re-render behavior and performance Without Memoisation With Memoisation Re-render on parent update All children re-render Only changed children re-render Props comparison No comparison, always re-render Shallow compare, skip if same Performance cost High for large subtrees Low, only memoised components Memory overhead None Small for memoisation caches Best use case Simple, small components Expensive or frequently updated componen THECODEFORGE.IO
thecodeforge.io
React Performance Optimisation

Production Benchmarks from Real Migrations

Real-world migrations to the React Compiler show substantial performance gains. In one case, a large e-commerce app reduced its commit count from 41 to 23 per interaction (a 44% reduction) and average render duration from 14.2ms to 9.1ms (a 36% improvement). These benchmarks were measured using React DevTools Profiler on a typical product listing page. The compiler eliminated unnecessary re-renders caused by inline functions and object literals. Another migration of a dashboard app saw a 30% reduction in frame drops during heavy data updates. These numbers demonstrate that the compiler is not just a developer convenience but a real performance booster.

Benchmark ResultsTEXT
1
2
3
4
5
6
7
8
9
Before React Compiler:
- Commits per interaction: 41
- Average render duration: 14.2ms

After React Compiler:
- Commits per interaction: 23
- Average render duration: 9.1ms

Improvement: 44% fewer commits, 36% faster renders
💡Measure your own app
🎯 Key Takeaway
Real migrations show 30-44% reduction in re-renders and 36% faster render times with the React Compiler.
● Production incidentPOST-MORTEMseverity: high

The Dashboard That Froze for 3 Seconds on Every Keystroke

Symptom
Typing in a search input caused the entire dashboard (20+ charts, tables, and widgets) to freeze for 2-3 seconds. Console showed React was calling every component function on every keystroke, even those that didn't depend on the search query.
Assumption
The assumption was that React was efficient enough out of the box; if a component's props don't change, React won't re-render it.
Root cause
No memoisation on any component. The search state was kept in a top-level context, and every child re-rendered due to cascading render. Additionally, inline object literals and arrow functions in JSX created new references every render, making React.memo useless even if it had been applied.
Fix
Applied React.memo to stable chart components, wrapped expensive callbacks in useCallback, and stabilised data objects with useMemo. Also moved the search-specific state down into a dedicated component to isolate the re-render scope. The freeze dropped from 3 seconds to under 200ms.
Key lesson
  • Always profile before optimising — React DevTools Profiler shows exactly which components re-render and why.
  • Stabilise references of props passed to memoised children: useMemo for objects/arrays, useCallback for functions.
  • Move state as close as possible to where it's used to limit re-render scope.
Production debug guideSymptom → Action guide for identifying and fixing React performance issues4 entries
Symptom · 01
Component re-renders even though its props look the same
Fix
Open React DevTools > Profiler > Highlight updates when components render. Check the component's props in the 'Change' column — look for new object/array/function references on each render.
Symptom · 02
useMemo or useCallback seems to have no effect
Fix
Verify the dependency array. If you omitted a dependency, the memoised value is stale. If you included a new object reference each render, memoisation is useless. Use eslint-plugin-react-hooks/exhaustive-deps to catch both.
Symptom · 03
React.memo component still re-renders when parent re-renders
Fix
Check every prop for referential instability. Inline objects ({ theme: 'dark' }) create new references. Move static values outside the component, or wrap dynamic ones in useMemo. Also check that you aren't passing a new function reference (useCallback needed).
Symptom · 04
Large list re-renders on every state update, causing frame drops
Fix
If the list has 100+ items, apply virtualisation (@tanstack/react-virtual) to only render visible rows. For smaller lists, use React.memo on list item components and stabilise their props.
★ Quick React Performance Debugging Cheat SheetCommands and actions to diagnose and fix performance issues fast.
Janky UI on state updates
Immediate action
Open browser DevTools > Performance tab, record interaction, look for long 'Function Call' or 'Rendering' blocks.
Commands
React DevTools Profiler -> Record -> Trigger the interaction -> Inspect 'Commits' > 'What caused this update'
Add console.log('%c[RENDER] ComponentName', 'color:blue') in every component to see which ones re-render on each action.
Fix now
Use React.memo on leaf components, stabilise props with useMemo/useCallback, and avoid creating new objects/functions in render.
Slow initial page load+
Immediate action
Check Network tab — if a single JS bundle is > 500KB, it's a code splitting candidate.
Commands
In Chrome DevTools: Run Lighthouse > Performance > Metrics > Total Blocking Time
Run `npx source-map-explorer build/static/js/main.*.js` to see what's in your bundle.
Fix now
Add React.lazy on route-level components and heavy third-party libs (chart libs, PDF viewers) that aren't needed above the fold.
Memory grows over time, page becomes sluggish+
Immediate action
Take heap snapshot in Chrome DevTools > Memory, compare two snapshots after interaction.
Commands
In React DevTools Profiler, enable 'Record why each component rendered' and look for 'Parent re-rendered' cause.
Check for missing cleanup in useEffect: return a cleanup function that removes event listeners, subscriptions, or aborts fetch requests.
Fix now
Ensure all effects have cleanup functions, avoid storing large objects in state that aren't needed, and use WeakMap for caches if applicable.
React Performance Techniques Comparison
TechniqueWhat it preventsWhen to useHidden cost
React.memoUnnecessary re-renders when props haven't changedMemoised children receiving stable props from a frequently-re-rendering parentShallow props comparison on every render — wasted if props change often
useMemoExpensive recalculations and unstable object/array referencesComputationally heavy derivations, or objects/arrays passed as props to memoised childrenMemory allocation for the cache, dependency array comparison, cognitive overhead
useCallbackNew function references causing memoised children to re-renderCallbacks passed as props to React.memo'd components or as dependencies in other hooksSame memory overhead as useMemo — pointless without React.memo on the receiving component
React.lazy + SuspenseOversized initial JS bundle delaying Time to InteractiveRoute-level splits, heavy optional features (charts, editors, PDF viewers)Extra network request on first use — mitigate with prefetching
VirtualisationRendering thousands of DOM nodes at once causing slow mount and scrollLists with 100+ dynamic items where pagination isn't viableBreaks Ctrl+F search, complicates variable-height rows, removes items from document flow
⚙ Quick Reference
16 commands from this guide
FileCommand / CodePurpose
ReconcilerDemo.jsxfunction ProductCard({ product, onAddToCart }) {How the React reconciler actually decides what to re-render
MemoisedProductCard.jsxconst ProductCard = React.memo(function ProductCard({ product, onAddToCart }) {React.memo, useMemo and useCallback
AppWithCodeSplitting.jsxconst AnalyticsDashboard = lazy(() => import('./pages/AnalyticsDashboard'));Code splitting with React.lazy, Suspense and dynamic imports
VirtualisedProductList.jsxfunction generateProductCatalogue(count) {Virtualisation for long lists
useRenderCounter.jsexport function useRenderCounter(componentName = 'Component') {Profiling React Performance
AntiPattern.jsxfunction UserGreeting({ firstName, lastName }) {When NOT to optimise
ProfileBottleneck.jsfunction ExpensiveList({ items }) {How to Measure First (Before You Touch a Line of Code)
SplitContexts.jsconst AppContext = createContext();Optimize Context Usage (Or Watch Your Entire App Re-Render)
DebouncedSearch.jsfunction useDebounce(value, delay = 300) {Debounce Rapid Updates (Stop Chasing Every Keystroke)
fibWorker.jsconst worker = new Worker('fibWorker.js');Web Workers
OptimizedImage.jsxfunction OptimizedImage({ src, alt, width, height }) {Optimizing Images
ssrExample.jsfunction App({ user }) {SSR
Before vs After React Compilerconst ExpensiveList = React.memo(({ items, onItemClick }) => {React Compiler (React Forget)
babel.config.jsmodule.exports = {5-Step Migration Guide for Adopting the React Compiler
IncompatibleComponent.jsxfunction MyForm() {Incompatible Libraries
Benchmark ResultsBefore React Compiler:Production Benchmarks from Real Migrations

Key takeaways

1
React re-renders children by default whenever a parent re-renders
React.memo opts a component out of this by caching its output and only re-rendering when props change via shallow equality (===).
2
useCallback and useMemo are reference-stabilisation tools first, and computation-savers second
their most important job is preventing new object/function references from invalidating React.memo.
3
Code splitting with React.lazy + dynamic import() reduces initial bundle size at the route or feature level
combine it with mouseenter prefetching to eliminate visible loading delays on predictable navigation.
4
Virtualisation with @tanstack/react-virtual keeps the DOM node count constant regardless of dataset size
mount time and memory usage stay flat whether your list has 100 or 100,000 items.
5
Profile before optimising. Premature memoisation adds overhead and complexity. Use React DevTools Profiler to find real bottlenecks, then apply optimisations surgically.
6
Restructuring component state (lifting or lowering state) often solves performance issues better than any memoisation hook.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
If React.memo does a shallow comparison of props, how would you handle a...
Q02SENIOR
Explain the relationship between useCallback and React.memo. Can React.m...
Q03SENIOR
A colleague says useMemo is always a good idea because 'it can only help...
Q04SENIOR
How would you debug a situation where a React app feels janky but you ca...
Q01 of 04SENIOR

If React.memo does a shallow comparison of props, how would you handle a component that receives a deeply nested object as a prop where only a nested field changes — and why is the standard advice 'flatten your state' rather than 'use a custom comparator'?

ANSWER
Shallow comparison won't detect deep changes. A custom comparator (second argument to React.memo) can perform deep comparison, but that's expensive and usually a code smell. Flattening state means splitting the deeply nested object into separate props or using multiple smaller components so that only the relevant part triggers a re-render. For example, instead of passing a user object with 20 fields, pass name, email, role as separate props. That way, React.memo compares each primitive directly, and only the display component for the changed field re-renders. In practice, if flattening isn't feasible, consider using useMemo on the parent to return a stable reference to the deeply nested object, and only update that reference when the relevant nested field changes. But that often requires manual dependency tracking and is error-prone. The cleaner path is refactoring the component hierarchy.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Does React.memo do a deep comparison of props?
02
When should I use useCallback vs useMemo?
03
Does splitting code with React.lazy hurt SEO?
04
Can virtualised lists be accessible?
05
What's the difference between the React Profiler and the browser Performance tab?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.

Follow
Verified
production tested
July 19, 2026
last updated
2,466
articles · all by Naren
🔥

That's React. Mark it forged?

11 min read · try the examples if you haven't

Previous
Animations with Framer Motion
3 / 40 · React
Next
Code Splitting and Lazy Loading