Blog

Part 36 · Profiling and Debugging React Apps

Measure before you make React faster. Use the Profiler component, React DevTools and the browser’s Performance panel, then find bugs with errors, logs and Strict Mode.

Part 18 showed when React renders. Parts 19 to 22 showed ways to skip renders: composition, memo, useMemo and useCallback, and the React Compiler. Parts 19 to 21 each said the same thing: measure first.

This part shows how to measure. Profiling means measuring which parts of a program run, how often, and for how long. We’ll use three tools: React’s <Profiler> component, the React Developer Tools, and the browser’s Performance panel. Then we’ll follow one slow list through the whole loop: measure, find, fix, measure again.

The second half is about debugging, which means finding and fixing a bug. We’ll read errors, log values, stop the code with debugger, and let Strict Mode find bugs for us.

Try this first

Read this code. Don’t press Run yet.

import { Profiler, useState, type ProfilerOnRenderCallback } from 'react'

const onRender: ProfilerOnRenderCallback = (id, phase) => {
  console.log('Profiler', id, phase)
}

function Counter() {
  const [count, setCount] = useState(0)
  console.log('Counter renders')
  return <button onClick={() => setCount(count + 1)}>Count: {count}</button>
}

export default function App() {
  return (
    <Profiler id="counter" onRender={onRender}>
      <Counter />
    </Profiler>
  )
}

<Profiler> is a component that comes with React. It wraps part of your app. Each time React renders that part and puts it on the page, React calls the onRender function you gave it.

Strict Mode is on in the playground, so “Counter renders” will show twice each time, as Part 2 explained. Now make a guess. Will “Profiler counter” show twice each time too?

Press Run, then click the button once. The Console shows this:

Counter renders
Counter renders
Profiler counter mount
Counter renders
Counter renders
Profiler counter update

“Counter renders” shows four times: two for the first render, two for the click. But the Profiler line shows only twice: once with mount, once with update. We counted with Strict Mode on and off. Both times, onRender ran once on load and once per click.

The reason is in Part 18. Strict Mode renders twice, but React commits once. onRender runs once per commit, when React puts the result on the page.

What <Profiler> tells you

<Profiler> needs two props. id is a name you choose, so you can tell one <Profiler> from another. onRender is your function. React calls it with six values:

Value What it is
id The id you gave the <Profiler>.
phase "mount" the first time, "update" after that. There is also "nested-update", explained below.
actualDuration How long this commit spent rendering the parts inside the <Profiler> that rendered in this commit.
baseDuration A guess of how long it would take to render everything inside, with nothing skipped.
startTime When React started rendering this update.
commitTime When React committed it.

All the times are in milliseconds (ms). There are 1,000 of them in one second.

Most of the time, you’ll read the two durations. A duration is how long something took. React’s docs say to “Compare actualDuration against it to see if memoization is working”. Here is why:

  • baseDuration adds up the last render time of every component inside. It is the cost if nothing were skipped.
  • actualDuration counts only what rendered this time.

So if actualDuration is close to baseDuration on every click, little was skipped. If it drops far below, memoization is working.

"nested-update" shows up when an update starts during a commit. We tried this. A component called its set function inside useLayoutEffect, which runs during the commit. Its second commit came with "nested-update". The same code in useEffect gave a plain "update".

Not in production

React’s docs warn: “Profiling adds some additional overhead, so it is disabled in the production build by default.” Overhead means extra work.

We checked this. We ran the slow list from the next section with React’s production build. onRender was called 0 times. Then we used React’s special profiling build, which is a production build with profiling left on. onRender was called once on load and once per click, as in development.

Measure, find, fix, measure again

Here is the loop we’ll follow:

  1. Measure. Get a number for the thing that feels slow.
  2. Find. Find which component takes the time.
  3. Fix. Use one of the fixes from Parts 19 to 22.
  4. Measure again. Check that the number went down. If not, undo the fix.

Step 1: measure

This list has ten rows. Each row waits 2 ms on purpose, to pretend it does slow work. A button above the list changes state in App.

import { Profiler, useState, type ProfilerOnRenderCallback } from 'react'

const fruits = ['Apple', 'Banana', 'Cherry', 'Grape', 'Lemon', 'Mango', 'Orange', 'Peach', 'Pear', 'Plum']

function Row({ name }: { name: string }) {
  // Wait 2 ms on purpose, to make this row slow. Real code never does this.
  const end = performance.now() + 2
  while (performance.now() < end) {
    // do nothing
  }
  return <li>{name}</li>
}

const onRender: ProfilerOnRenderCallback = (id, phase, actualDuration, baseDuration) => {
  console.log(id, phase, 'actual:', actualDuration.toFixed(1), 'base:', baseDuration.toFixed(1))
}

export default function App() {
  const [clicks, setClicks] = useState(0)
  return (
    <div>
      <button onClick={() => setClicks(clicks + 1)}>Clicks: {clicks}</button>
      <Profiler id="list" onRender={onRender}>
        <ul>
          {fruits.map((name) => <Row key={name} name={name} />)}
        </ul>
      </Profiler>
    </div>
  )
}

performance.now() gives the time in milliseconds. The while loop keeps checking it until 2 ms have passed. toFixed(1) shows a number with one place after the dot.

Press Run and click the button a few times. Each click logs one line, like list update actual: 41.0 base: 41.0. Your numbers will differ. In Firefox and in Safari’s engine, we saw only whole numbers, like actual: 40.0.

We ran this in jsdom, a pretend browser, and in a real Chrome browser with no window. With Strict Mode on, as in the playground, each click took about 40 ms on our computer, in both. We also ran it in jsdom with Strict Mode off: about 20 ms. Ten rows times 2 ms is 20 ms. Strict Mode renders each row twice, and actualDuration counts both.

Look at the two numbers on each click. actual is about the same as base. Nothing inside the list was skipped.

Is that slow? React’s useMemo page talks about one calculation. If it takes “say, 1ms or more”, the page says “it might make sense to memoize” it. Each row here takes 2 ms, and ten of them render on every click.

Step 2: find the slow component

<Profiler> tells you how long a part of the tree took. It doesn’t tell you which component inside it took the time. You have two ways to find out.

  • Wrap smaller parts. You can put a <Profiler> around any part of the tree, and nest them. Give each one a different id.
  • Use the React DevTools Profiler. It shows every component in each commit. We’ll look at it later in this part.

Here is the first way. The page now has a heading too. One <Profiler> wraps the whole page, and two smaller ones wrap the heading and the list:

import { Profiler, useState, type ProfilerOnRenderCallback } from 'react'

const fruits = ['Apple', 'Banana', 'Cherry', 'Grape', 'Lemon', 'Mango', 'Orange', 'Peach', 'Pear', 'Plum']

function Row({ name }: { name: string }) {
  // Wait 2 ms on purpose, to make this row slow. Real code never does this.
  const end = performance.now() + 2
  while (performance.now() < end) {
    // do nothing
  }
  return <li>{name}</li>
}

function Header({ clicks }: { clicks: number }) {
  return <h1>You clicked {clicks} times</h1>
}

const onRender: ProfilerOnRenderCallback = (id, phase, actualDuration) => {
  console.log(id, phase, 'actual:', actualDuration.toFixed(1))
}

export default function App() {
  const [clicks, setClicks] = useState(0)
  return (
    <Profiler id="page" onRender={onRender}>
      <button onClick={() => setClicks(clicks + 1)}>Click me</button>
      <Profiler id="header" onRender={onRender}>
        <Header clicks={clicks} />
      </Profiler>
      <Profiler id="list" onRender={onRender}>
        <ul>
          {fruits.map((name) => <Row key={name} name={name} />)}
        </ul>
      </Profiler>
    </Profiler>
  )
}

Run it and click once. Each click logs three lines, one for each <Profiler>. The two inner ones log first, then page. We clicked 20 times in Chrome. The middle values were 0.1 ms for header, 41.3 ms for list and 41.5 ms for page. The heading took almost no time. The list took almost all of the page’s time. So the list is the slow part, and we look inside it next.

The React Developer Tools draw each commit as a flamegraph: one bar for each component, with children under their parent. For our list, it would show ten Row bars that each took about the same time. Together they are almost the whole commit. And App rendered only because of the click, which had nothing to do with the rows.

1. You click. App renders again. Each bar is one component. 2. Without memo, every Row renders too. Wider bars took longer. 3. The ten rows are most of the commit's time. 4. With memo, click again. React compares each Row's props: the same. 5. Every Row is skipped. Here, grey means it did not render this time. a drawing, not a screenshot App Row Row Row Row Row Row Row Row Row Row rendered in this commit did not render (grey in this drawing)

One click, before and after memo, drawn as a flamegraph. Press play, or step through it.

This picture is a drawing of one commit, not a picture of the real tool. In the drawing, grey means “did not render”. The real tool marks those bars with a pattern. Here are the same steps in words.

  1. You click. App renders again. Each bar is one component that rendered.
  2. Without memo, every Row renders too. Wider bars mean more time.
  3. The commit’s time is mostly the ten rows.
  4. With memo, React compares each row’s props. They are the same.
  5. Every Row is skipped. Its bar turns grey: it did not render in this commit.

Step 3: fix

The rows get the same name every time. That is the case memo was made for, as Part 20 showed. Wrap Row in memo:

import { Profiler, memo, useState, type ProfilerOnRenderCallback } from 'react'

const fruits = ['Apple', 'Banana', 'Cherry', 'Grape', 'Lemon', 'Mango', 'Orange', 'Peach', 'Pear', 'Plum']

const Row = memo(function Row({ name }: { name: string }) {
  // Wait 2 ms on purpose, to make this row slow. Real code never does this.
  const end = performance.now() + 2
  while (performance.now() < end) {
    // do nothing
  }
  return <li>{name}</li>
})

const onRender: ProfilerOnRenderCallback = (id, phase, actualDuration, baseDuration) => {
  console.log(id, phase, 'actual:', actualDuration.toFixed(1), 'base:', baseDuration.toFixed(1))
}

export default function App() {
  const [clicks, setClicks] = useState(0)
  return (
    <div>
      <button onClick={() => setClicks(clicks + 1)}>Clicks: {clicks}</button>
      <Profiler id="list" onRender={onRender}>
        <ul>
          {fruits.map((name) => <Row key={name} name={name} />)}
        </ul>
      </Profiler>
    </div>
  )
}

Step 4: measure again

Press Run and click a few times. The first line, for mount, is about the same as before. The rows still have to render once to appear at all. But each click now logs something like list update actual: 0.0 base: 43.2.

We counted the row renders, too. Before the fix, one click rendered Row 10 times with Strict Mode off, and called it 20 times with it on. After the fix, a click rendered Row 0 times. actualDuration on a click went from about 40 ms to less than 1 ms, with Strict Mode on.

baseDuration stayed at about 40 ms. That is right. It is still the cost of rendering everything, if nothing were skipped.

One more check. We passed each row a new function, onPick={() => {}}, on every render. That broke memo, as Part 20 warned. The clicks went back to about 40 ms, and Row rendered on every click again. Measuring again is how you catch this.

Run it more than once

Timings jump around. Other programs, the browser’s own work and many other things change them. So don’t trust one click.

We clicked 20 times for each case and kept every number. The table shows the smallest, the middle and the largest, from jsdom with Strict Mode on. With 20 numbers, the middle is halfway between the 10th and the 11th. So it can have two places after the dot. They come from one computer. Yours will be different, but the pattern should hold.

Each click, 20 clicks, Strict Mode on Smallest Middle Largest
Before memo 43.0 ms 44.0 ms 49.5 ms
After memo 0.0 ms 0.0 ms 0.1 ms
memo, but a new onPick every time 41.4 ms 42.15 ms 42.9 ms

The React Developer Tools

The React Developer Tools are a browser add-on from the React team. React’s docs list them for Chrome, Firefox and Edge. After you install them and open a React site, your browser’s developer tools get two new panels: Components and Profiler.

Use them in a project on your own computer, like the one from Part 10. We did not open the add-on for this part, and we did not try it with the playground. The names below come from React’s docs and from the add-on’s source code.

The Profiler panel

This is the tool that Parts 18, 20, 21, 22 and 24 pointed to. It needs a development or profiling build of React. With a normal production build, the add-on’s source shows “Profiling not supported.”

  1. Open the Profiler panel. Press the record button. Its tooltip, the small note that shows when you hold the mouse over it, starts with “Start profiling”.
  2. Use your app the way a user would. Click the slow button, type in the box.
  3. Press the button again to stop.

The Profiler groups what it saw by commit. A small bar chart at the top has one bar for each commit. Pick a commit, and you see it in one of two views:

  • Flamegraph shows the tree of components, one bar each, as in the drawing above. React’s 2018 post about the Profiler explains it. The width of a bar is how long the component and its children took when they last rendered. The 2018 post says yellow took more time and blue took less. In today’s source, the color comes from the component’s own time in this commit, compared with the others. Its children’s time is not counted. A component that did not render gets a pattern instead of a color.
  • Ranked shows the same components in a list. The one that took the longest is at the top.

To see why a component rendered, open the Profiler’s settings. Turn on “Record why each component rendered while profiling”. Then record again, and click a component. A box called “Why did this render?” gives the reason. These are some of the answers in the add-on’s source:

  • “This is the first time the component rendered.”
  • “Props changed:”, followed by the names of the props.
  • A hook changed. A useState change in a function component shows up this way, with the hook’s number, like “Hook 1 changed”. (“State changed:” is for class components.)
  • “Context changed” or “Context changed:”.
  • “The parent component rendered.”

That last reason is the one from our slow list. In the add-on’s source it means the component’s props, state, hooks and context had no changes. If you see it on a slow component, memo may help.

Another setting, “Hide commits below”, hides commits faster than a number of ms you choose.

The Components panel

The Components panel shows your component tree. Click a component to see its props, its state and its hooks. React’s docs say you can also edit props and state there.

Two more things help here:

  • In the React Developer Tools’ general settings there is a box called “Highlight updates when components render”. Turn it on, and each component is marked on the page when it renders.
  • Part 22 said compiled components get a badge. React’s docs call it a “Memo ✨” badge. In the add-on’s source, the badge text is “Memo”, and the ✨ is in its tooltip: “✨ This component has been auto-memoized by the React Compiler.” We did not check this in a real browser.

The add-on also knows about Strict Mode. React’s Strict Mode page says that with the add-on, logs from the second render look a little lighter. The add-on’s Debugging settings can also hide them. That box is called “Hide logs during additional invocations in Strict Mode”. Invocations means calls.

The browser’s Performance panel

The browser has its own profiler. In Chrome, it is the Performance panel. You press record, use the page, and stop. It shows what the browser did in that time, like running JavaScript and network requests.

Since React 19.2, React adds its own rows to that panel. React’s docs call them Performance tracks. A track is one row on the timeline. The two main ones are named “Scheduler ⚛” and “Components ⚛”. The scheduler is the part of React that decides which work to do first.

  • The Scheduler track shows each piece of React’s work. One render is split into parts: “Update”, “Render”, “Commit” and “Remaining Effects”.
  • The Components track shows how long each component took, as a flamegraph. React’s docs say that in development, you can click a component there to see which props changed.

The docs say the tracks are “only available in development and profiling builds”. They also name some limits:

  • In a profiling build, only the Scheduler tracks are on by default.
  • In a profiling build, the Components track lists only components inside a <Profiler>, unless the React Developer Tools are installed.
  • The tracks show only in browsers that let pages add their own rows to the Performance panel.

We checked the development build in a Chrome browser with no window. We recorded a trace, which is a saved recording of what the browser did, while clicking the slow list. The trace had entries on “Scheduler ⚛” and “Components ⚛”. The Components track had one Row entry for each row on each click. The Scheduler track had parts named “Render”, “Commit” and “Remaining Effects”.

React’s useMemo page gives one more tip: “your machine is probably faster than your users’”. The CPU is the part of a computer that runs code. Chrome has an option called “CPU Throttling”. Throttling means slowing down on purpose. Chrome’s own docs say to use it “Whenever you profile a page”.

Measure a production build

Your users run the production build. Development does more work: React runs extra checks, and Strict Mode renders twice. React’s useMemo page says: “measuring performance in development will not give you the most accurate results.” The DevTools Profiler can’t help here. It works only with a development or profiling build.

But <Profiler> is off in production, and so are the Performance tracks. That is what the profiling build is for. React’s docs say to use react-dom/profiling instead of react-dom/client. They suggest an alias. That is a setting that tells the build tool to load one package in place of another. In a Vite project, that goes in vite.config.ts:

import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: { 'react-dom/client': 'react-dom/profiling' },
  },
})

We tried this in the Part 10 project. We added the slow list with its <Profiler>, and built it twice. npm run build worked with the alias. We ran each built file in jsdom, under Node. Without the alias, onRender never ran. With it, onRender ran on load and on each click.

The numbers changed too. With the profiling build, a click on the slow list took about 20 ms, not 40. The rows rendered once each, because Strict Mode does nothing in production. But development with Strict Mode off took about 21 ms in jsdom, close to the profiling build. So here, almost all of the difference was Strict Mode’s double render. How much extra the development build itself costs depends on the app.

Use the alias only while you measure. React’s docs say the profiling code “adds some additional overhead”. Our built file was also bigger: 237,638 bytes with the alias, against 220,149 without it.

Debugging: when something is wrong

Profiling is for “it works, but it’s slow”. Debugging is for “it doesn’t work”. Here are the tools. We start with the most basic one.

Read the error

An error can look long. Start with its first line. Run this:

type User = { name: string }

const users: User[] = []

function Greeting() {
  const user = users[0]
  return <p>Hello, {user.name}!</p>
}

export default function App() {
  return <Greeting />
}

In Chrome, the playground shows Cannot read properties of undefined (reading 'name'). Firefox and Safari word it differently. Firefox says can't access property "name", user is undefined. Safari says undefined is not an object (evaluating 'user.name'). Read the message slowly. Something was undefined, and the code tried to read name from it. The list is empty, so users[0] is undefined.

TypeScript did not catch this one. It trusts that users[0] is a User, unless you turn on a setting called noUncheckedIndexedAccess. We checked: with it on, TypeScript said 'user' is possibly 'undefined'. A new Vite project doesn’t turn it on.

In a real project, the browser’s Console shows more. We let the same error happen in React 19.3’s development build, with React’s own error handling. The error itself went to the browser as an uncaught error: an error that no code caught. React also printed a warning: An error occurred in the <Greeting> component. That tells you where to look. Part 32 showed the component stack, which lists every component from the one that broke up to the top.

Log what you need to see

console.log is still the fastest tool. Give each log a label, so you can find it:

const cart = [{ name: 'Apple', price: 2 }, { name: 'Pear', price: 3 }]
console.log('cart', cart)

Remember the doubles from Strict Mode when you count lines. To time one piece of code, use console.time and console.timeEnd, as Part 21 showed.

The browser’s Console has other helpers. console.table(cart) prints an array of objects as a table, one row for each object. The playground’s Console area on this page shows only console.log, console.info, console.warn and console.error. So try console.table in your own project.

Stop the code with debugger

Put the word debugger on its own line inside a component or a handler:

import { useState } from 'react'

export default function App() {
  const [count, setCount] = useState(0)

  function handleClick() {
    debugger
    setCount(count + 1)
  }

  return <button onClick={handleClick}>Count: {count}</button>
}

Open your browser’s developer tools first. Then click. The browser stops at that line. You can read every variable, and run the code one line at a time. In the playground, you see your code after the playground turned it into plain JavaScript. So it looks a little different.

If the developer tools are closed, nothing happens. MDN says that then “this statement has no effect”. Remove debugger lines when you are done.

Let Strict Mode find bugs

Strict Mode is not only something to put up with. It is a bug finder. Read this code and guess the numbers:

let count = 0

function Ticket() {
  count = count + 1
  return <li>Ticket number {count}</li>
}

export default function App() {
  return (
    <ul>
      <Ticket />
      <Ticket />
      <Ticket />
    </ul>
  )
}

Run it. The page says 2, 4 and 6, not 1, 2 and 3.

Ticket changes a variable outside itself while it renders. That breaks the rule from Part 11: rendering must be pure. Strict Mode calls each component twice, so the bug shows at once.

Without Strict Mode, the bug hides. We turned Strict Mode off, and the page said 1, 2 and 3. Then we made the parent render again, and it said 4, 5 and 6. Your users would see the numbers jump, but only after some other change. That kind of bug is hard to find.

The fix: work the number out from props, not from a variable outside. Give each ticket its number, like <Ticket number={1} />.

Tools from other people

There are packages that report renders for you. One is why-did-you-render. Its own page says it changes React’s own code while your app runs. Then it tells you about re-renders you might not need. The same page says it should “NEVER be used in production”, because it “significantly slows down React”. Its page also says it was not tested with the React Compiler, and probably doesn’t work with it. It can’t run in the playground, which can only import react and react-dom.

Most of the time you won’t need it. The Profiler’s “Why did this render?” answers the same question.

Common mistakes

Making code faster without measuring

import { memo } from 'react'

const Title = memo(function Title({ text }: { text: string }) {
  return <h1>{text}</h1>
})

Wrapping a tiny component in memo “just in case” adds code to read, and saves almost nothing. React’s memo page suggests the profiler instead, to “see which components would benefit the most from memoization”. The fix: measure first. Fix the component that takes the time.

Treating development times as real times

list update actual: 41.0 base: 41.0

“Our users wait 41 ms on every click.” That line came from development, with Strict Mode on. In our slow list, a click took about 40 ms there. In the profiling build, about 20 ms. But development with Strict Mode off gave about 21 ms too. So the big gap was the double render. How much the development build itself adds depends on the app. Development numbers are good for comparing before and after, on the same setup. They are not what your users feel. React’s memo page says: “make sure that React is running in the production mode”. The fix: confirm with a profiling build, on a slow device or with CPU throttling.

Counting Strict Mode’s double renders

function Row({ name }: { name: string }) {
  console.log('Row renders', name)
  return <li>{name}</li>
}

With this console.log, each row logs two lines per click in development. That looks like twice the work. In production it is once. As Part 18 said, divide the log lines from a component body by two. Or count commits with onRender, which Strict Mode does not double.

Trusting one run

list update actual: 159.7 base: 159.6

That was the first click on the slow list in our Chrome run. The next clicks took about 41 ms. One slow click doesn’t mean the code got slower. In our table, the fastest click before memo took 43.0 ms, and the longest took 49.5 ms. Same code, same computer. Some clicks are slower for reasons that have nothing to do with your code. The fix: repeat the action many times. Compare the middle values, before and after.

Turning Strict Mode off to stop the doubles

import { createRoot } from 'react-dom/client'

function App() {
  return <p>Hello</p>
}

// Strict Mode removed, to stop the double logs.
createRoot(document.getElementById('root')!).render(<App />)

The doubles are there to find bugs like Ticket. Turning Strict Mode off hides the bug. It doesn’t fix it. To measure without the doubles, use a profiling build.

Practice

Press Edit on the examples above and try these.

  1. In “Try this first”, put a second <Counter /> inside the same <Profiler>. Click the first counter once. How many “Profiler” lines does the click add? How many “Counter renders” lines?
  2. In the fixed list (Step 3), give each row a new function: <Row key={name} name={name} onPick={() => {}} />. Add onPick: () => void to the props type. Click a few times. What happens to actual?
  3. In the slow list (Step 1), change the wait from 2 ms to 1 ms. About how much should actual change on a click?
  4. Fix the Ticket example, so the page says 1, 2 and 3 with Strict Mode on.
Answers
  1. One “Profiler counter update” line, because one click is one commit. Two “Counter renders” lines: only the counter you clicked renders, and Strict Mode doubles it. The other counter’s state didn’t change, and App didn’t render, so React has no reason to render it.
  2. actual goes back up to about the same as base, near 40 ms with Strict Mode on. Each render makes a new function, so memo sees a changed prop and renders every row. In our test, each click rendered Row 10 times, called 20 times with Strict Mode.
  3. It should drop to about half. Ten rows times 1 ms is 10 ms, or about 20 ms with Strict Mode’s double render. In our run with Strict Mode on, the middle value went from 44.0 ms to 20.8 ms. Your numbers will be different.
  4. Pass the number in as a prop:
function Ticket({ number }: { number: number }) {
  return <li>Ticket number {number}</li>
}

export default function App() {
  return (
    <ul>
      <Ticket number={1} />
      <Ticket number={2} />
      <Ticket number={3} />
    </ul>
  )
}

Interview questions

Try to answer each one out loud before you open the answer.

A React page feels slow. What do you do?

Measure first. Check that the slow part is rendering at all. It might be the network, a big download of JavaScript (Part 34), or a very long list (Part 35). The browser’s Performance panel shows all of these. If it is rendering, record the slow action with the React DevTools Profiler. Find the commits that take the longest, and the components that take the time in them. Check why they rendered. Then pick a fix: move state down, pass children, memo, useMemo, or the React Compiler. Then record again, and keep the fix only if the time went down.

A strong answer adds two things. Confirm with a profiling build, because development does extra work, like Strict Mode’s double render. And test on a slow device or with CPU throttling, because your computer is probably faster than your users’.

What is the <Profiler> component?

A component that comes with React. You wrap part of the tree in it, with an id and an onRender function. React calls onRender after each commit inside that part. It passes the id, the phase ("mount", "update" or "nested-update"), actualDuration, baseDuration, startTime and commitTime.

A strong answer says it is off in production builds. To use it there, you need React’s profiling build, react-dom/profiling. It also says each <Profiler> adds a little work, so don’t leave many in the app.

What is the difference between the two durations the Profiler reports?

actualDuration is the time spent rendering in this commit: only the components that really rendered. baseDuration adds up each component’s most recent render time, as if nothing were skipped. If the two are close on every update, little is being skipped. If actualDuration drops far below, memoization is working. In our slow list, memo took a click from about 40 ms to less than 1 ms. baseDuration stayed at about 40 ms.

A strong answer adds that the mount is different. On the first render nothing can be skipped, so the two are close. Compare them on updates.

Does Strict Mode change what the Profiler component reports?

It doesn’t change the number of calls. Strict Mode renders twice but commits once, so onRender runs once per commit. In our test that was once on load and once per click, with Strict Mode on or off. But it does change the times. actualDuration counts both renders, so our slow list took about 40 ms with Strict Mode and about 20 ms without it.

A strong answer adds what to do about it. Compare times only between runs with the same setting. For numbers close to what users get, use a profiling build, where Strict Mode does nothing.

How do you find out why a component rendered?

In the React DevTools Profiler, turn on “Record why each component rendered while profiling”, record, and click the component. It says, for example, “Props changed:” with the names. Or it says “The parent component rendered.” The Components panel shows the current props and state. A quick console.log of the props works too. Some teams use a package like why-did-you-render, but only in development.

A strong answer adds what to do with the reason. “The parent component rendered” with the same props means memo or composition can help. “Props changed” for a function or object prop means it is new on every render. useCallback or useMemo in the parent can keep it the same.

How does Strict Mode help you find bugs?

In development it renders each component twice, and runs Effects setup, cleanup, setup on mount. Pure code gives the same result both times. Code that changes things while rendering gives a different result, so the bug shows at once. In our Ticket example, a counter outside the component gave 2, 4 and 6 instead of 1, 2 and 3. Without Strict Mode the page looked right until the parent rendered again. None of this happens in production.

A strong answer adds that turning Strict Mode off to stop double logs is a mistake. It hides bugs like this one. The React Developer Tools can make the second log lighter, or hide it.

What are React’s Performance tracks?

Since React 19.2, React adds its own rows to the browser’s Performance panel. The Scheduler track shows React’s work in parts: update, render, commit, and the remaining Effects. The Components track shows how long each component took, as a flamegraph. They work in development and in profiling builds, not in normal production builds. They help when you need to see React’s work next to the browser’s own work, like network requests.

A strong answer adds the limits. In a profiling build only the Scheduler tracks are on by default. The Components track there lists only components inside a <Profiler>, unless the React Developer Tools are installed. And the browser must let pages add their own rows.

Sources

  • <Profiler>, react.dev: the id and onRender props, the six values, “Compare actualDuration against it”, “disabled in the production build by default”, the profiling build, nesting, and “some CPU and memory overhead”.
  • React Developer Tools, react.dev: the browsers, the Components and Profiler panels, and editing props and state.
  • React Performance tracks, react.dev: the Scheduler and Components tracks, “Update”, “Render”, “Commit”, “Remaining Effects”, changed props in development, “only available in development and profiling builds”, and react-dom/profiling with a bundler alias.
  • React 19.2, React blog: Performance tracks are new in 19.2, and the names “Scheduler ⚛” and “Components ⚛”.
  • Introducing the React Profiler, React blog, 2018: commits, the flame chart’s widths and colors, and the ranked chart.
  • The React DevTools source on GitHub, main branch, read on 7 October 2026: ProfilerSettings.js, GeneralSettings.js, DebuggingSettings.js, RecordToggle.js, Profiler.js, WhatChanged.js, ForgetBadge.js, HookChangeSummary.js, CommitFlamegraphListItem.js and ProfilingNotSupported.js: the exact names of the buttons, views, settings and reasons.
  • memo and useMemo, react.dev: use the profiler to find what would benefit, “running in the production mode”, “say, 1ms or more”, “it might make sense to memoize”, console.time, development timings, and CPU throttling.
  • StrictMode, react.dev: double renders, and dimmed logs in React DevTools.
  • Analyze runtime performance, Chrome for Developers: the Performance panel and CPU throttling.
  • debugger and console.table(), MDN.
  • The why-did-you-render README, on GitHub: what it does, not to use it in production, and not tested with the React Compiler.
  • Installation, react.dev: the “Memo ✨” badge.
  • The call counts, phases, row counts and timings above come from running React 19.3.0 for this post, in jsdom and in headless Chrome, Firefox and Safari’s engine (WebKit). The timings are from one computer. The build results come from the Part 10 Vite project, run in jsdom under Node.
  • This part follows the Profiling & Debugging kata in react-katas. The kata counts renders by changing a ref while rendering. Part 14 explains why not to. Use onRender or a console.log instead.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.