Skip to content

5.1 — Render model & profiling

Module 5 · Lesson 1 · 🟡 Intermediate · ~40 min

What you'll learn

  • What actually triggers a re‑render (and the three myths that waste weeks)
  • Reading the React Profiler flamegraph and the ranked chart
  • Correlating a React render with a browser long task
  • A repeatable profiling workflow for interaction latency

What triggers a re‑render

A component re‑renders when:

  1. Its own state changes (useState, useReducer)
  2. A context it consumes changes value
  3. Its parent re‑renders — regardless of whether its props changed
  4. A store it subscribes to notifies it (useSyncExternalStore)

Rule 3 is the one that surprises people and causes most commerce render storms.

function ProductListing() {
  const [sort, setSort] = useState('featured');
  return (
    <>
      <SortSelect value={sort} onChange={setSort} />
      {/* These re-render on every sort change even though their props
          are identical — because the parent re-rendered. */}
      <CategoryHeader />
      <FilterSidebar />
      <ProductGrid products={products} />     {/* 48 cards × ~20 nodes */}
      <Pagination />
    </>
  );
}

Three myths

Myth 1: "Re‑rendering is expensive." A re‑render is React calling your function and diffing the result. For a simple component that's microseconds. It's expensive when multiplied by hundreds of components, or when the component body does real work.

Myth 2: "A re‑render means a DOM update." No — React reconciles and only touches the DOM where output differs. Re‑rendering 48 identical cards produces zero DOM mutations. The cost is the render functions and the diffing, not the DOM.

Myth 3: "React.memo everywhere makes it faster." Every memo adds a props comparison. If props change every render (a new object or inline function), you pay the comparison and the render. See 5.2.

The right question is never "does this re‑render?" but "how much work does this re‑render cost, and does it exceed a frame budget?" Profile before optimizing.


The React Profiler

Install React DevTools, open the Profiler tab, and enable these settings first:

  • ⚙️ → "Record why each component rendered while profiling" — essential
  • ⚙️ → "Highlight updates when components render" — a great always‑on sanity check while developing

Recording an interaction (the important case)

  1. Load the page and let it settle.
  2. Start recording.
  3. Perform one interaction (one filter toggle, one variant change).
  4. Stop recording.

You'll see one or more commits — each bar at the top is a commit, its height proportional to duration.

Commits:  ▁▁█▁▁          ← the tall one is the problem
          0 1 2 3 4

The three views

Flamegraph — the component tree for the selected commit. Width = time including children. Gray components didn't render.

ProductListingPage ████████████████████████████████ 412ms
├─ SortSelect ▏1ms
├─ CategoryHeader ▎2ms                    ← re-rendered for nothing
├─ FilterSidebar ████ 38ms                ← re-rendered for nothing
└─ ProductGrid ██████████████████████████ 368ms
   ├─ ProductCard ██ 7.6ms  × 48          ← the real cost
   └─ ...

Ranked — the same commit sorted by self time. Start here; it takes you straight to the expensive component.

"Why did this render?" — select a component and read the right panel:

Reason Meaning Fix
"Props changed: (onSelect)" An inline function or object prop useCallback/useMemo, or the compiler
"Hooks changed" A context or store value changed Split the context (5.3)
"The parent component rendered" Cascade memo on the child, or restructure
"This is the first render" Mount Not a re‑render problem

Correlating with browser long tasks

The React Profiler tells you which components; the browser's Performance panel tells you whether it mattered. Use both.

Chrome Performance panel, one filter toggle at 4× CPU:

Main ──┬─ Event: click                                       2ms
       ├─ Task ████████████████████████████████████  412ms  🔴 LONG TASK
       │   └─ performWorkUntilDeadline
       │       └─ renderRootSync
       │           ├─ ProductGrid              368ms
       │           └─ FilterSidebar             38ms
       ├─ Layout                                             68ms
       ├─ Paint                                              22ms
       └─ (next frame)                                       ▲ INP ≈ 504ms

The workflow:

  1. Record in the Performance panel; note the long task's duration → that's your INP contribution.
  2. Record the same interaction in the React Profiler → that tells you which components.
  3. Fix.
  4. Re‑record both. A React‑side improvement that doesn't shrink the long task didn't help.

Add User Timing marks so your own interactions appear in the browser timeline:

'use client';
export function FilterCheckbox({ facet, value }: Props) {
  const [, startTransition] = useTransition();

  const onChange = (checked: boolean) => {
    performance.mark('filter-start');
    startTransition(() => {
      applyFilter(facet, value, checked);
      // Measure to the next paint, not to the end of the handler
      requestAnimationFrame(() => {
        requestAnimationFrame(() => {
          performance.measure('filter-apply', 'filter-start');
          const [m] = performance.getEntriesByName('filter-apply').slice(-1);
          reportMetric({ name: 'filter_apply', value: m.duration });
        });
      });
    });
  };
  return <input type="checkbox" onChange={(e) => onChange(e.target.checked)} />;
}

The double requestAnimationFrame is the standard trick to measure to the next painted frame — the first rAF runs before paint, the second after. That's what INP measures, so that's what you should measure.


The commerce render storms

Four patterns cause most React performance problems on commerce sites.

Storm 1 — the PLP filter cascade

// ❌ Filter state at the page level: every toggle re-renders everything
function CategoryPage() {
  const [filters, setFilters] = useState<Filters>({});
  const products = useFilteredProducts(filters);
  return (
    <>
      <FilterSidebar filters={filters} onChange={setFilters} />
      <ProductGrid products={products} />       {/* 48 cards, 412ms */}
    </>
  );
}

Fixes, in order of impact: 1. startTransition so the grid update is interruptible and the checkbox responds instantly (5.5) 2. Virtualize the grid so only ~12 cards render (5.4) 3. memo the cards with a stable props shape (5.2) 4. Move filter state to the URL so it's server‑driven and cacheable

Storm 2 — the mini‑cart context

// ❌ One context holding cart items, open state, and a loading flag.
//    Opening the drawer re-renders every consumer — including all 48 product cards
//    that only wanted `addToCart`.
const CartContext = createContext<{
  items: CartItem[];
  isOpen: boolean;
  isLoading: boolean;
  addToCart: (id: string) => void;
}>(null!);

Fix: split the context, or move to an external store with selectors (5.3).

Storm 3 — the search‑as‑you‑type re‑render

Every keystroke re‑renders the results list synchronously. At 8 keystrokes/second and 60 ms per render, the input stops responding.

Fix: useDeferredValue for the results, keep the input's own state urgent (5.5).

Storm 4 — the scroll listener

// ❌ setState on every scroll event → a render per frame (or more)
useEffect(() => {
  const onScroll = () => setScrollY(window.scrollY);
  window.addEventListener('scroll', onScroll);
  return () => window.removeEventListener('scroll', onScroll);
}, []);

Fixes: use CSS (position: sticky, scroll‑driven animations) instead of JS; if you must use JS, use an IntersectionObserver (which fires on threshold crossings, not every frame) and always { passive: true } on scroll listeners.

// ✅ Sticky header shadow without a single React render
// CSS: .header { position: sticky; top: 0 }
// A sentinel element + IntersectionObserver toggles a class directly on the DOM.
useEffect(() => {
  const sentinel = document.getElementById('scroll-sentinel');
  const header = document.getElementById('site-header');
  if (!sentinel || !header) return;
  const io = new IntersectionObserver(
    ([e]) => header.classList.toggle('is-stuck', !e.isIntersecting),
    { threshold: 0 },
  );
  io.observe(sentinel);
  return () => io.disconnect();
}, []);

A profiling workflow you can repeat

1. Reproduce on a throttled device      (4× CPU minimum; this is not optional)
2. Record the browser Performance trace  → is there a long task? how long?
3. Record the React Profiler             → which components, and why?
4. Form ONE hypothesis                   → "the grid re-renders because filters live at page level"
5. Make ONE change
6. Re-record both traces                 → did the long task shrink?
7. Verify in the field                   → did p75 INP move?

Steps 4–5 are where teams go wrong. Changing five things at once means you can't attribute the result, and you'll keep three changes that did nothing and one that made it worse.

What "good" looks like

Measure Target Notes
Longest task during an interaction < 200 ms Under the INP "good" threshold with room for input delay
Single component self time < 16 ms One frame
Components rendered per interaction As few as necessary 48 cards for a filter change is not necessary
Commits per interaction 1–2 More means cascading state updates

Multiple commits per interaction is a specific smell: it usually means an effect is setting state in response to a render, causing a second pass.

// ❌ Two commits per interaction: render → effect → setState → render
const [products, setProducts] = useState([]);
useEffect(() => {
  setProducts(applyFilters(allProducts, filters));   // derived state in an effect
}, [filters, allProducts]);

// ✅ One commit: derive during render
const products = useMemo(() => applyFilters(allProducts, filters), [allProducts, filters]);

Derived state belongs in render, not in an effect. This single rule eliminates a large share of double‑render problems in commerce codebases.


Profiling production builds

Dev builds are 3–10× slower than production and include extra work you'll never ship. Numbers from a dev build are useless for prioritization.

// next.config.ts — build with React profiling enabled, production optimizations on
const config: NextConfig = {
  reactStrictMode: true,
  // Keeps component names and Profiler hooks in a production build
  productionBrowserSourceMaps: false,
  experimental: {
    reactCompiler: false,   // profile with and without, separately
  },
};
# Next.js supports a profiling build for React DevTools
next build --profile
next start

Then profile against localhost:3000 with the production bundle. Remember Strict Mode double‑invokes render functions in development — another reason dev numbers mislead.


Common mistakes

Mistake Consequence
Profiling a dev build 3–10× inflated numbers; wrong priorities
Profiling unthrottled Everything looks fine; users disagree
Chasing re‑render counts instead of time Optimizing renders that cost 0.2 ms
Changing several things per experiment No attribution
Only using the React Profiler Misses layout/paint/third‑party cost
Only using the browser panel Doesn't tell you which component
Ignoring commit count Misses effect‑driven double renders
Deriving state in effects Guaranteed double render

Lab 5.1 — Profile your worst interaction

  1. Pick the interaction with the worst field INP (from your RUM interactionTarget leaderboard — 1.3).
  2. Reproduce it locally on a production build at 4× CPU throttling.
  3. Record a browser Performance trace. Write down: long task duration, its breakdown (scripting/layout/paint), and the INP estimate.
  4. Record a React Profiler session for the same interaction. Write down: commit count, longest commit, top 5 components by self time, and the "why did this render" reason for each.
  5. Form one hypothesis. Make one change.
  6. Re‑record both. Did the long task shrink? By how much?
  7. Add a User Timing measure so this interaction is tracked in the field.

Checklist

  • Profiling happens on production builds, throttled
  • "Record why each component rendered" is enabled
  • Both React Profiler and browser Performance panel used together
  • Long‑task duration, not render count, is the metric
  • One change per experiment
  • Commit count per interaction is 1–2
  • No derived state computed in effects
  • Key interactions have User Timing marks reported to RUM

Next: 5.2 Memoization & React Compiler