React Profiler Guide: Finding Performance Bottlenecks Fast

Written By  Crosscheck Team

Content Team

May 22, 2026 11 minutes

React Profiler Guide: Finding Performance Bottlenecks Fast

How to use the React Profiler to find slow renders in 2026

The React Profiler is the React DevTools tab that records every component render in a session, shows how long each render took, and tells you exactly what triggered it — so you can pinpoint the one component dragging the rest of the tree down without guessing. In 2026 the workflow has shifted: React 19.2 is current stable, the React Compiler reached 1.0 in October 2025 and now auto-memoizes most components at build time, and the Profiler integrates with the new React Performance Tracks in Chrome DevTools. This guide covers reading the flame graph, the 10 bottlenecks that account for most slow React apps, and when to stop optimising because the compiler already did it for you.

Key takeaways

  • Width = how long the component took to render last; colour = cost in the selected commit. Yellow is expensive, blue is cheap, grey did not render at all this commit.
  • React 19.2 shipped the <Profiler> integration with Performance Tracks — wrapped subtrees appear in the Chrome DevTools Performance panel even in profiling builds.
  • The React Compiler 1.0 (stable since October 2025) auto-inserts memoization at build time. In Compiler-enabled projects, treat new useMemo / useCallback like a manual for loop — usually unnecessary, occasionally still right.
  • "Why did this render?" is opt-in. Toggle it in the Profiler gear menu before recording or you'll only see durations, not causes.
  • The most common bottleneck in 2026 codebases is still the same one it was in 2019: a parent re-rendering and dragging an un-memoized child subtree with it.

What the React Profiler is, and where it lives

The Profiler ships inside the React DevTools browser extension for Chrome, Firefox, and Edge. Install the extension and a Profiler tab appears next to Elements and Console. For React Native, a standalone Electron version ships via the react-devtools package.

Three things to know before opening it:

  • It only works on development builds by default. Production builds strip the profiling hooks — the tab will say "Profiling not supported." A production-with-profiling build is covered below.
  • It records commits, not renders. One commit can include hundreds of component renders.
  • It's React-specific. It complements the Chrome Performance panel — Chrome shows JavaScript and layout, Profiler shows component-level render time. For the browser side, see Chrome DevTools performance auditing.

Recording a profile: start, interact, stop

The flow is three buttons and one decision.

  1. Open DevTools, switch to the Profiler tab.
  2. Click the gear icon and tick "Record why each component rendered while profiling." Default is off, and without it the profile shows durations but not causes.
  3. Click record, perform the interaction, click record again to stop.

The Profiler now shows a row of vertical bars across the top — one per commit, height and colour proportional to how long it took. Click any bar to inspect that commit.

Keep recordings short (3–6 seconds is readable; 30 seconds is scroll-soup), profile in an incognito window to keep extensions out of the trace, and ignore the first commit — it's the initial mount and dominates the timeline. For an interaction profile, look at the commits around the input event.


The three views: flame graph, ranked, component

After recording, the left sidebar shows three tabs.

Flame graph

The flame graph is the default view and the one you'll spend most of your time in. It draws the React tree for the selected commit as a horizontal stack of bars, one per component. The dimensions encode two different things — the part most people get wrong on first contact:

DimensionWhat it means
Bar widthHow long this component took the last time it rendered — not necessarily this commit.
Bar colourHow long the component took in the selected commit specifically.

Colour scale: blue = fast in this commit, green/yellow = slower, orange/red = top optimization candidates, grey solid = did not render this commit (good — memoization worked), grey diagonal stripes = didn't render and isn't on the rendering path.

The pattern to hunt for is a wide yellow bar with grey children — a parent that rendered slowly while its children stayed memoized. That's usually a fixable hotspot. The pattern that's a problem is a wide yellow bar with wide yellow children — a cascading re-render where every level did work.

Ranked

The ranked view sorts every component in the commit by render time, top to bottom — the flame graph's "show me the worst offenders" mode. Use it when the flame graph is too deep to scan visually, common in apps using a UI library like MUI or Chakra where the tree is 20 levels of wrappers. Click any component to jump back to the flame graph centred on it.

Component

The component view pins to a single component and shows its render history across every commit in the trace. Useful for "this component is rendering more than I expect — when and why?" Pick a component from the flame graph, switch to the component tab, and you see one bar per commit it rendered in. Click any bar to see what changed.


"Why did this render?" — the feature that earns its keep

With the option enabled before recording, the right-hand details panel lists exactly what triggered each render:

  • Props changed: ({propName}) — the listed prop changed by reference. The most common cause and the most worth investigating.
  • State changedsetState or a hook update fired. Usually expected.
  • Context changed — a useContext value changed. Worth checking if the context is wide.
  • The parent component rendered — your component isn't memoized.
  • Hook 1 changed, Hook 2 changed — a useState, useReducer, or useSyncExternalStore value updated. The number is the hook's position in the component.

If the reason says "Props changed: ({onClick})" every commit and the parent's onClick is an inline arrow function, you've found bottleneck #2 below.


React 19 and React Compiler: what actually changed

If you last used the Profiler in React 17 or 18, three things matter for 2026.

1. The Profiler API is unchanged. <Profiler> still accepts an id and an onRender callback. The signature is (id, phase, actualDuration, baseDuration, startTime, commitTime) — same as React 16.5. The phase value is "mount", "update", or "nested-update" (the third was added to distinguish state updates triggered inside another update).

2. React 19.2 added Performance Tracks. Wrap a subtree in <Profiler> and it now also appears as a custom track inside the Chrome DevTools Performance panel — even in profiling builds — alongside React's internal Scheduler track (Blocking, Transition, Suspense, Idle) and Server tracks for React Server Components. You can finally see React renders next to network requests and layout work in a single timeline.

3. React Compiler 1.0 changes the optimization advice. The compiler is a Babel/SWC plugin that analyses each component at build time and inserts memoization automatically. In a Compiler-enabled codebase, React.memo, useMemo, and useCallback are largely unnecessary for patterns the compiler can recognise. It's conservative — when it can't statically prove a value is stable, it leaves it alone. It does not virtualize lists, change effect dependency arrays, or solve context-fanout problems. A "use no memo" directive at the top of a file opts that file out.

Meta reports up to 12% faster initial loads and 2.5× faster interactions in the Quest Store after Compiler adoption — on top of an already-optimised app, not from compiler-only magic. The 2026 advice: turn the compiler on for new code, write components naturally, and reach for useMemo / useCallback only when the Profiler shows the compiler missed something.


10 common React bottlenecks and how to fix each

The Profiler shows that a component re-rendered. Fixing it is a vocabulary problem — most slow React apps fail one of the ten patterns below. Each one below: the Profiler symptom, then the fix.

1. New object or array literals in props

Profiler reason: "Props changed: ({options})". The literal is a fresh reference every render.

// Bad — { sortBy: 'date' } is a new object every render
function Parent() {
  return <Table data={rows} options={{ sortBy: 'date', limit: 50 }} />;
}

// Good — options is a stable reference across renders
const TABLE_OPTIONS = { sortBy: 'date', limit: 50 };
function Parent() {
  return <Table data={rows} options={TABLE_OPTIONS} />;
}

If the object depends on props or state, useMemo the construction. In Compiler-enabled code, write the literal inline and let the compiler hoist it.

2. Inline functions breaking memo

A React.memo'd child renders anyway. Profiler reason: "Props changed: ({onClick})".

// Bad — onClick is a new function every render, defeating React.memo
const Row = React.memo(({ item, onClick }) => (
  <li onClick={onClick}>{item.name}</li>
));
function List({ items }) {
  return items.map(item => (
    <Row key={item.id} item={item} onClick={() => console.log(item.id)} />
  ));
}

// Good — stable callback wrapped in useCallback
const Row = React.memo(({ item, onClick }) => (
  <li onClick={() => onClick(item.id)}>{item.name}</li>
));
function List({ items }) {
  const handleClick = useCallback(id => console.log(id), []);
  return items.map(item => (
    <Row key={item.id} item={item} onClick={handleClick} />
  ));
}

In a Compiler-enabled project, write the first version — the compiler memoizes the function.

3. Unstable context value

Every consumer of a Context re-renders when the value changes by reference. If you pass an object literal as the value, every render of the provider triggers every consumer.

// Bad — value is a new object every render
<UserContext.Provider value={{ user, signOut }}>

// Good — memoize the value object
const contextValue = useMemo(() => ({ user, signOut }), [user, signOut]);
<UserContext.Provider value={contextValue}>

4. A big list rendered without virtualization

The Profiler shows a single commit taking 200ms+, the flame graph mostly one wide bar containing thousands of list items. Fix: virtualize. TanStack Virtual and react-window render only the rows in the viewport plus a small buffer. A 10,000-row table drops from 800ms to 12ms.

5. Deeply nested context fanout

One context value, fifty consumers, value changes once — every consumer re-renders. The Profiler shows wide bars in unrelated subtrees. Fixes in order of preference: split the context (one for auth, one for theme, one for cart), select a slice with a useContextSelector library, or move state into a store with subscription-based reads (Zustand, Jotai).

6. useEffect with broad dependencies

A child renders, then immediately renders again. Reason: an effect fired, set state, triggered another render. The dependency array is too coarse — an effect depending on a whole user object fires when any user field changes. Depend on user.id instead. The eslint-plugin-react-hooks exhaustive-deps rule catches missing deps but not over-broad ones.

7. Prop drilling cascade

State lives at the top of the tree and threads through eight components to reach a leaf. When it changes, all eight re-render — including the seven that don't use it. Fix: move the state into a store, or use composition — pass the leaf as children so it's already-rendered JSX by the time the intermediate components see it.

8. Expensive computation not in useMemo

A single component takes 80ms+ to render — sorting 5,000 rows, parsing markdown, transforming a dataset.

// Bad — sorted every render even when input hasn't changed
function Report({ rows, sortKey }) {
  const sorted = [...rows].sort((a, b) => b[sortKey] - a[sortKey]);
  return <Table rows={sorted} />;
}

// Good — recompute only when inputs change
function Report({ rows, sortKey }) {
  const sorted = useMemo(
    () => [...rows].sort((a, b) => b[sortKey] - a[sortKey]),
    [rows, sortKey]
  );
  return <Table rows={sorted} />;
}

The Compiler catches most of these — verify with the Profiler that it actually did before assuming.

9. Parent re-renders dragging children

Every child of a parent renders and "Why did this render?" says "The parent component rendered" for each one. This is the default React behaviour and not always a problem — it only matters when the children's renders are expensive. Fix: wrap expensive children in React.memo (or rely on the compiler), keep their props stable, and audit any context they read.

10. Missing keys in lists

List items re-render in full when items are added or removed, even though only one item changed.

// Bad — index as key. React thinks every item changed when you prepend a new one.
{
  items.map((item, i) => <Row key={i} item={item} />);
}

// Good — stable identity-based key
{
  items.map(item => <Row key={item.id} item={item} />);
}

This is a correctness bug as much as a performance one — index keys also break local component state when the list reorders.


When not to optimize

The Profiler will happily show 200 components rendering on every keystroke. Most don't matter. A render that takes 1ms isn't worth optimising — even 10 times an interaction, that's 10ms that wasn't blocking anything.

The heuristic that holds up: optimize when a single commit exceeds 16ms (one frame at 60fps) or when the Profiler shows a commit that user-visibly drops frames. Below that, the cost of the optimization — code complexity, stale-closure risk — usually exceeds the benefit. The 2026 corollary, post–React Compiler: don't pre-emptively wrap things in useMemo and useCallback. If the compiler is on, it does this where it helps. If not, profile first and optimize the actual hotspot.


Production profiling with the <Profiler> component

The DevTools Profiler is dev-only. For production observability — "is this slow for real users on real devices?" — wrap subtrees in the <Profiler> component and send the onRender data to your observability stack.

import { Profiler } from 'react';

function logRender(
  id,
  phase,
  actualDuration,
  baseDuration,
  startTime,
  commitTime
) {
  // Send to your analytics / observability stack
  if (actualDuration > 16) {
    analytics.track('react.slow_render', {
      component: id,
      phase, // "mount" | "update" | "nested-update"
      actualDuration, // ms spent rendering this subtree in this commit
      baseDuration, // worst-case render time, no memoization
      startTime,
      commitTime,
    });
  }
}

export default function App() {
  return (
    <Profiler id="Dashboard" onRender={logRender}>
      <Dashboard />
    </Profiler>
  );
}

Two things to know in 2026. First, profiling is disabled in the default production build — collecting <Profiler> data in production needs a profiling-enabled build (next build --profile, react-scripts build --profile, or bundler aliasing of react-dom/profiling and scheduler/tracing-profiling). Second, the overhead is small but real — most teams enable it on 1–5% of sessions rather than all of them.

The pattern that works: pick three to five subtrees that represent the routes users care about, wrap each in a named <Profiler>, and send commits over a sampling threshold to your backend. Pair with Core Web Vitals — LCP, INP, CLS for a complete view.


FAQ

How do I install the React Profiler?

Install the React Developer Tools browser extension for Chrome, Firefox, or Edge. The Profiler tab appears next to Elements and Console when DevTools is open on a React page in development mode. For React Native, install the standalone react-devtools package.

Why does the Profiler say "Profiling not supported"?

Production React builds strip the profiling hooks. The DevTools Profiler only works against development builds or a production-with-profiling build (next build --profile, react-scripts build --profile, or the equivalent bundler flag).

Does React Compiler replace useMemo and useCallback?

For most patterns, yes. In a Compiler-enabled codebase, write components naturally and the compiler memoizes expressions, props, and callbacks where it can prove they're safe. Use useMemo and useCallback only when the Profiler shows the compiler missed something and the un-memoized cost is over the 16ms-per-frame threshold.

What's the difference between actualDuration and baseDuration?

actualDuration is how long the subtree took in the current commit, with the benefit of any memoization. baseDuration is the worst case — no memoization. If actualDuration stays close to baseDuration on updates, memoization isn't helping.

Why does "Why did this render?" sometimes only say "Hook 3 changed"?

The DevTools detect that a hook updated but can't always recover its name from a minified build. The number is the hook's position in the component, counting top to bottom. useDebugValue helps make custom hooks more identifiable.


Where Crosscheck fits

Finding a slow render in the Profiler is half the work. The other half is filing the bug so the engineer doesn't have to reproduce the session from a screenshot. A "Dashboard takes 800ms on filter change" ticket without network conditions, device profile, and trace data is one the developer immediately closes for more info.

Crosscheck is a free Chrome extension that captures the screen recording, console logs, network requests, and browser metadata in one click and sends the package to Jira, Linear, ClickUp, GitHub, or Slack. Pair it with the Profiler workflow above and a perf finding becomes a fix-ready ticket. For the upstream debugging side, see JavaScript debugging tips and Chrome DevTools performance auditing.

Try Crosscheck free

Related Articles

Contact us
to find out how this model can streamline your business!
Crosscheck Logo
Crosscheck Logo
Crosscheck Logo

Speed up bug reporting by 50% and
make it twice as effortless.

Overall rating: 5/5