Senior 7 min · March 05, 2026

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
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
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
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.

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.

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

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)
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)
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.
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.
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.
● 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

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.

Common mistakes to avoid

5 patterns
×

Defining objects or arrays inline inside JSX props on a memoised component

Symptom
React.memo never skips a re-render because every render creates a new reference for the inline object/array. The shallow comparison always sees a change.
Fix
Move static objects outside the component. For dynamic ones, wrap them in useMemo so the reference stays the same across renders unless the underlying data changes.
×

Using useCallback with a stale closure by omitting state variables from the dependency array

Symptom
The callback always operates on the initial value of the omitted state variable. Updates to that variable are never seen, causing hard-to-debug bugs where clicks or events trigger stale data.
Fix
Either include all referenced state in the dependency array, or use the functional updater form (setState(prev => prev + 1)) which doesn't need to close over the current value.
×

Wrapping everything in useMemo and useCallback 'just in case'

Symptom
No measurable performance improvement, but code is harder to read and refactor. The overhead of dependency comparisons and memory allocation may actually degrade performance in hot paths.
Fix
Profile with React DevTools Profiler first, identify components with high render times or render counts, then apply memoisation surgically where the measurement shows a real gain.
×

Forgetting to add a key or using index as key when rendering lists

Symptom
When the list order changes, React can't match components to previous instances, leading to unnecessary re-renders and potential state loss in controlled inputs.
Fix
Always use a stable, unique identifier as the key (e.g., database ID). Avoid using index unless the list is static and never reordered.
×

Lazy loading components that are needed above the fold

Symptom
The initial page load is slower because the lazy component's chunk needs to be fetched before rendering, causing a visible flash or empty state for critical UI.
Fix
Only lazy load components that are not in the critical rendering path. Use route-level splitting for pages below the fold, or heavy third-party components that can appear after initial paint. For above-fold components, consider direct imports with proper code splitting via other mechanisms (e.g., React Server Components).
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?
🔥

That's React.js. Mark it forged?

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

Previous
React Context API
10 / 47 · React.js
Next
Redux with React