cf.completefrontend
Open lab My progress
THE REACT FIELD GUIDE

Profiling React apps

Find what's slow in a React app: record with the DevTools Profiler, read flamegraphs, ask why a component rendered, use <Profiler>, and React's Chrome tracks.

By the end, you can
  • 01
    Read a React profileRecord an interaction in the DevTools Profiler, read its commits, flamegraph, and ranked chart, and find the component that took the time.
  • 02
    Find out why a component renderedTurn on "Why did this render?" and highlight updates, and tell a changed prop, a changed Hook, and a parent's render apart.
  • 03
    Measure in code and in the browserLog actualDuration and baseDuration with <Profiler>, and read React's Scheduler and Components tracks in Chrome's Performance panel.

Find the slow part before you fix it

When a page feels slow, it's tempting to guess which part is to blame and start changing code. Guesses are often wrong. Profiling means measuring the page while it runs: which components rendered, how long each one took, and why it rendered at all. Then you fix the part that really took the time. Here is a small tea shop with a search box. The result is live: click in the box and type mint quickly.

A tea shop where typing feels slowPop out in the code editor (opens in a new tab)JSX
import { useState } from "react";import ProductList from "./ProductList.jsx";import Recommendations from "./Recommendations.jsx"; function CartBadge({ count }) {  return <p>Cart: {count}</p>;} export default function ShopPage() {  const [query, setQuery] = useState("");  const [cartCount, setCartCount] = useState(0);  return (    <>      <CartBadge count={cartCount} />      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search teas" />      <ProductList query={query} onAdd={() => setCartCount(cartCount + 1)} />      <Recommendations />    </>  );}
ResultLoading…

Line 10 keeps the search query in state, and line 11 the cartCount. Line 14 shows the count with CartBadge (lines 5 to 7). Line 15 is the search box. Line 16 renders ProductList, which filters four teas by the query in its tab, and gives each an Add button. Line 17 renders Recommendations, 15 picks from the Recommendations.jsx tab.

The letters appear late, a few at a time. Which part is slow? ProductList does the searching, so it looks like the one to blame. It isn't. Open the Recommendations.jsx tab: lines 3 to 6 make each Pick wait 3 milliseconds on purpose, like a card with a lot to draw. 15 picks take at least 45 milliseconds, and about twice that in development, because StrictMode calls every component twice (the Keeping components pure lesson explains why). The picks don't use the query at all, yet every key draws them again. You couldn't tell that by reading App.jsx. A profile shows it in one look, and this lesson shows you how to take one.

Four steps in a loop. 1, record the slow interaction. 2, find the widest bar. 3, ask why that component rendered. 4, fix one thing. An arrow from step 4 goes back to step 1: measure again.1. Recordthe slow interaction2. Findthe widest bar3. Ask whywhy did it render?4. Fixone thing at a timerecord again
Profiling is a loop. Record the interaction that feels slow, find the part that took the time, ask why it rendered, fix one thing, and record again to see if the fix worked.
Real-life analogyTiming a slow kitchen

Orders at a restaurant are slow. The owner could hire another cook for the salad station, where it always looks busy. Instead, the manager times each station during one rush. The grill takes almost all the time, because it cooks every steak again each time a new table orders. One change at the grill fixes it. The next night, she times the rush again to be sure.

In real life: Orders come out slowly on a busy night
In React: Typing in the search box feels slow
In real life: The manager times each station during the rush
In React: You record a profile while you type
In real life: The grill took 9 of the 10 minutes
In React: Recommendations took about 45 of the 46 milliseconds
In real life: The grill was cooking steaks nobody ordered
In React: "Why did this render?" says: the parent component rendered
In real life: After a change, the manager times the next rush
In React: After a fix, you record again and compare
The main idea

Profiling a React app means recording what it renders while you use it: each commit, which components rendered in it, how long they took, and why they rendered. Record the slow interaction, find the part that took the time, fix one thing, and record again.

Record a profile in React DevTools

React DevTools is a browser extension for Chrome, Firefox, and Edge. The Setting up a React project lesson installs it and uses its Components tab. Its other tab, Profiler, records your app while you use it. On a page made with React, open the browser's developer tools and choose the Profiler tab. Then:

  1. Press the round Start profiling button (the record button) at the top left of the tab.
  2. Do the slow thing in the page, and only that: type a few letters in the search box.
  3. Press the button again to stop.

The Profiler groups what it saw by commit. As Render and commit showed, React first renders (calls your components to find out what changed), then commits (puts the changes on the page). Each key you typed made one render and one commit. A row of small bars at the top right of the tab is the list of commits: one bar per commit, taller and warmer in color when it took longer. Click a bar to look at that commit.

The Profiler can draw a commit in two ways, as two charts you switch between at the top of the tab:

  • The Flamegraph draws the component tree: each bar is a component, with its children in the row below it. A bar's width is how long that component and its children took the last time they rendered. Its color shows how long the component took on its own in this commit, from cool (quick) to yellow (slow). A gray bar is a component that didn't render in this commit at all. Each bar's label reads like Recommendations (0.2ms of 45.2ms): the component's own time, then its time with its children.
  • The Ranked chart lists only the components that rendered in this commit, one per row, sorted by their own time, slowest first. A slow component deep in the tree comes up to the top.
Two flamegraphs of a commit made by typing one letter. Top, plain Recommendations: ShopPage, CartBadge, ProductList, Recommendations and its 15 Pick bars all rendered. Bottom, Recommendations in memo: Recommendations and its 15 picks are gray because they didn't render, but their bars are just as wide.You type a letter, plain RecommendationsShopPageCartBadgeProductListRecommendationsYou type a letter, Recommendations in memoShopPageCartBadgeProductListRecommendations (Memo)15 Pick bars under Recommendations
Each bar is a component, with its children below it. A bar's width is how long the component and its children took the last time they rendered, so the 15 picks make Recommendations wide. Top: everything rendered. Bottom, with memo, Recommendations and its picks didn't render: same width, gray. The two small bars are drawn wider than they are, so their names fit.

The Profiler only shows components, not the <input> or <li> elements inside them. Hover over a bar for a summary. Click it, and the chart zooms in on that component, and the panel on the right lists every commit it rendered in, under Rendered at.

You can try all of this without the extension. The playground runs the same shop on this page, next to a small model of the Profiler tab. Type in its search box and press Add: each change adds a commit. Click a commit bar, switch between Flamegraph and Ranked, and click the bars. Then choose Recommendations in memo and type again.

Playground: a model of the Profiler tab
The shopRecommendations

Cart: 0

  • Green tea
  • Black tea
  • Mint tea
  • Chai
Picked for you
  1. Pick 1
  2. Pick 2
  3. Pick 3
  4. Pick 4
  5. Pick 5
  6. Pick 6
  7. Pick 7
  8. Pick 8
  9. Pick 9
  10. Pick 10
  11. Pick 11
  12. Pick 12
  13. Pick 13
  14. Pick 14
  15. Pick 15
Profiler
Try it yourself
Waiting for the first commit

The shop hasn't rendered yet.

A model of the React DevTools Profiler, built for this lesson. The shop runs on this page with React, and the model records which components really rendered in each commit and why, the way DevTools works it out. The times are fixed numbers, 3 ms per pick and a fraction of a millisecond for the rest, not measurements, so the picture is the same on every computer. Changing the version starts a new recording.

With plain Recommendations, every commit after a key has the same picture: Recommendations and its 15 picks rendered, and they make up almost all of the commit's time. With Recommendations in memo, the first render is the same, but after that the picks are gray when you type. The flamegraph keeps them just as wide, since a bar's width is the time from the last render it did, and the commit itself drops to well under a millisecond in this model. The next section explains the third panel, under the chart.

Ask why each component rendered

Knowing that Recommendations is slow is half the answer. The other half is why it rendered. DevTools can record that too, but only if you ask before you record: open the Profiler's settings (the gear icon) and turn on Record why each component rendered while profiling. Then, when you select a component in a commit, the panel on the right shows a heading Why did this render? with one of these answers:

How to read the table: each row is one answer DevTools can give, what it means, and what to look at next.

What "Why did this render?" tells you
DevTools saysWhat it meansWhat to look at
This is the first time the component rendered.The component just appeared on the page.Nothing, unless it appears again and again. A key that changes on every render remounts it.
Hook 1 changedThe value of the component's first Hook is new, like its first useState after a set call.Expected after its own state changes. Ask whether that state needs to live this high in the tree.
Props changed: onAddA prop isn't the same value as last time, compared with Object.is.A function or object made during the parent's render is new every time, even when it does the same thing.
Context changedA context the component reads has a new value.Every reader renders when the value changes. Split the context, or keep fast-changing values out of it.
The parent component rendered.Nothing the component uses changed. It rendered because its parent did.This is the render you can usually skip: move state down, pass it as children, or wrap it in memo.

For a function component, DevTools counts its Hooks in the order the component calls them. In ShopPage, Hook 1 is the useState for query on line 10, and Hook 2 the one for cartCount on line 11. So typing makes DevTools say Hook 1 changed for ShopPage, and Add says Hook 2 changed. When DevTools can read the names of your variables, it shows them beside the numbers.

Go back to the playground with plain Recommendations, type a letter, and click each bar in that commit:

  • ShopPage: Hook 1 changed. Its own state changed, so it rendered.
  • ProductList: Props changed: query, onAdd. The query really changed. onAdd did too, because line 16 makes a new arrow function every time ShopPage renders.
  • CartBadge, Recommendations, and every Pick: The parent component rendered. Nothing they use changed.

That last answer is the one to look for. A render where nothing changed does no useful work: React calls the component, gets the same result, and puts nothing new on the page. For 15 slow picks, that's about 45 milliseconds of work for no change. The fix the playground's second version uses is memo: wrapped in memo, a component is skipped when its props are the same as last time. Here is the same shop, with only the last line of the Recommendations.jsx tab changed to export default memo(Recommendations);. Type mint quickly again:

The same shop, with the picks wrapped in memoPop out in the code editor (opens in a new tab)JSX
import { useState } from "react";import ProductList from "./ProductList.jsx";import Recommendations from "./Recommendations.jsx"; function CartBadge({ count }) {  return <p>Cart: {count}</p>;} export default function ShopPage() {  const [query, setQuery] = useState("");  const [cartCount, setCartCount] = useState(0);  return (    <>      <CartBadge count={cartCount} />      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search teas" />      <ProductList query={query} onAdd={() => setCartCount(cartCount + 1)} />      <Recommendations />    </>  );}
ResultLoading…

The letters keep up now. App.jsx is the same as before; in the Recommendations.jsx tab, line 1 imports memo and line 25 exports memo(Recommendations). memo has a lesson of its own, memo, useMemo and useCallback, and so does The React Compiler, which adds the same skipping for you at build time. The next lesson shows fixes that change the structure instead, like moving the search state into a component of its own. Whichever fix you choose, the profile tells you where to put it, and a second profile tells you if it worked.

One more setting helps before you record anything, and When React re-renders showed where it is: in DevTools' general settings, turn on Highlight updates when components render. While you use the page, DevTools draws a short-lived outline around each component that renders. Typing in the plain shop, you'd see the picks light up on every key. Tick the same box in the playground to see it there.

Measure in code with the Profiler component

DevTools measures while you watch. React also lets your own code measure: wrap part of the tree in <Profiler>, a component built into React, and give it a function. React calls that function after every commit that renders anything inside it. Here the shop measures its two lists. Type a letter, then open the console under the result:

Two Profilers log every commitPop out in the code editor (opens in a new tab)JSX
import { Profiler, useState } from "react";import ProductList from "./ProductList.jsx";import Recommendations from "./Recommendations.jsx"; function logRender(id, phase, actualDuration, baseDuration) {  console.log(`${id} ${phase}: actual ${actualDuration.toFixed(1)} ms, base ${baseDuration.toFixed(1)} ms`);} export default function ShopPage() {  const [query, setQuery] = useState("");  const [cartCount, setCartCount] = useState(0);  return (    <>      <p>Cart: {cartCount}</p>      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search teas" />      <Profiler id="ProductList" onRender={logRender}>        <ProductList query={query} onAdd={() => setCartCount(cartCount + 1)} />      </Profiler>      <Profiler id="Recommendations" onRender={logRender}>        <Recommendations />      </Profiler>    </>  );}
ResultLoading…

Line 1 imports Profiler from React. Lines 16 to 18 wrap ProductList in a <Profiler> with an id and an onRender function, and lines 19 to 21 do the same for Recommendations. Lines 5 to 7 are logRender: React calls it with what was rendered and how long it took, and it prints one line. After the page loads and you type one letter, the console shows something like this (your numbers will differ):

The console after the first render and one keyText
ProductList mount: actual 0.4 ms, base 0.2 ms
Recommendations mount: actual 92.1 ms, base 90.6 ms
ProductList update: actual 0.1 ms, base 0.1 ms
Recommendations update: actual 91.3 ms, base 91.3 ms

The first two lines come from the first render, and the next two from one key. The picks wait at least 45 ms, yet the log says about 90: in development, StrictMode calls each component twice, and the Profiler counts both calls. React passes onRender six values, in this order:

How to read the table: each row is one value React passes to onRender, in order.

What onRender receives
ValueWhat it holds
idThe id you gave the <Profiler>, so you can tell several apart.
phase"mount" for the first render, "update" for a later one, or "nested-update" when a layout effect set state during the commit.
actualDurationMilliseconds spent rendering the tree inside this <Profiler> in this commit. Skipped components add nothing.
baseDurationMilliseconds the whole tree would take if every component in it rendered, added up from each one's last render.
startTimeWhen React started rendering this update, as a timestamp.
commitTimeWhen React committed this update. Every <Profiler> in one commit gets the same value.

actualDuration and baseDuration are the two worth comparing. The first is what this commit really cost. The second is what it would cost if every component inside rendered, added up from each one's last render. When they're about equal, as they are here, everything inside rendered. Now wrap the picks in memo: same App.jsx, and the Recommendations.jsx tab exports memo(Recommendations), as in the last section. Type a letter again:

The same Profilers, with the picks in memoPop out in the code editor (opens in a new tab)JSX
import { Profiler, useState } from "react";import ProductList from "./ProductList.jsx";import Recommendations from "./Recommendations.jsx"; function logRender(id, phase, actualDuration, baseDuration) {  console.log(`${id} ${phase}: actual ${actualDuration.toFixed(1)} ms, base ${baseDuration.toFixed(1)} ms`);} export default function ShopPage() {  const [query, setQuery] = useState("");  const [cartCount, setCartCount] = useState(0);  return (    <>      <p>Cart: {cartCount}</p>      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search teas" />      <Profiler id="ProductList" onRender={logRender}>        <ProductList query={query} onAdd={() => setCartCount(cartCount + 1)} />      </Profiler>      <Profiler id="Recommendations" onRender={logRender}>        <Recommendations />      </Profiler>    </>  );}
ResultLoading…

Now each key logs something like Recommendations update: actual 0.0 ms, close to zero, while baseDuration stays at about 90. React still calls onRender, because the <Profiler> itself got new props from ShopPage, but nothing inside it rendered. A small actualDuration next to a large baseDuration is how you see, in numbers, that skipping works.

Two commits of the Recommendations Profiler, each made by typing a letter. Plain: actualDuration and baseDuration are both long bars of about the same length. With memo: actualDuration is near zero, and baseDuration is as long as before.Plain Recommendations, you type a letteractualDurationbaseDurationRecommendations in memo, you type a letteractualDuration: near 0baseDuration
baseDuration is what the whole tree would cost if every component in it rendered. actualDuration is what this commit really cost. When memo skips the picks, the first drops to almost nothing and the second stays the same.

Three things to know before you add Profilers to an app:

  • Production builds turn it off. Measuring has a small cost, so in a normal production build React never calls onRender. React ships a profiling build for that: a production build with measuring left on. You use it by importing react-dom/profiling instead of react-dom/client, usually with an alias in your bundler, shown below for Vite. Some frameworks have a setting that does it for you.
  • Development numbers run slow. StrictMode's second call and React's extra checks make every component slower in development. Compare numbers with each other, before and after a change, not with a goal like "under 16 ms".
  • Each Profiler costs a little. Wrap the parts you're asking about, not every component. Profilers can nest, and several can share one onRender; the id tells them apart.
vite.config.js: use React's profiling buildJavaScript
// vite.config.js
import { defineConfig } from "vite";

export default defineConfig({
  resolve: {
    alias: {
      "react-dom/client": "react-dom/profiling",
    },
  },
});

Line 7 tells Vite: whenever code imports react-dom/client, use react-dom/profiling instead. Both have the same createRoot, so your main.jsx doesn't change. This is a build setting, so it runs in Node, not in the page, and has no result to show.

React's tracks in Chrome's Performance panel

React DevTools only sees React. Sometimes the time goes somewhere else: the browser working out the layout, painting, a network request, or another script. Chrome's own Performance panel records all of it on one timeline. The Measuring performance lesson shows how to record with it. In short: open the panel, press record, do the slow thing, and stop. The Main track then shows every piece of work the page's main thread did, and marks any task longer than 50 milliseconds as a long task.

Since React 19.2, React adds its own tracks to that timeline (the React 19.2 release post announced them, and React Performance tracks describes each one). They appear in development builds without any setup, and their names end in React's atom symbol:

  • Scheduler shows React's work by priority, in rows: Blocking for urgent updates, like a key press, Transition for updates inside startTransition, Suspense for showing fallbacks and revealing content, and Idle for the lowest priority. Each update shows as its phases in order: Update (what started it), Render, Commit, and Remaining Effects (your useEffect code).
  • Components shows which components rendered, as a flamegraph laid out over time, under the Render phase that rendered them. Effects that take 0.05 ms or more show too. Labels like Mount mark a part of the tree that just appeared. In development, clicking a component's entry shows which of its props changed.
Chrome's Performance panel after typing a letter. The Main track shows one task for the input event. Under it, React's Scheduler track has a Blocking row with four phases in order: Update, Render, Commit, Remaining Effects. React's Components track shows ShopPage, and Recommendations under it, during the Render phase.PerformanceMainTask: input eventSchedulerBlockingUpdateRenderCommitRemaining EffectsComponentsShopPageRecommendations
One key press in the Performance panel. React's Scheduler track shows the update's phases on the Blocking row, and the Components track shows which components rendered during the Render phase, as a flamegraph over time.

Every live result on this page runs React 19.3.1 as a development build, so you can try it here in Chrome. Open the developer tools on this page, choose Performance, press record, type a few letters in the next result, and stop. Look for the Scheduler and Components tracks under the timeline.

A search that copies its matches into statePop out in the code editor (opens in a new tab)JSX
import { useEffect, useState } from "react";import RenderCount from "./RenderCount.jsx"; const teas = ["Green tea", "Black tea", "Mint tea", "Chai"]; export default function TeaSearch() {  const [query, setQuery] = useState("");  const [matches, setMatches] = useState(teas);   useEffect(() => {    setMatches(teas.filter((tea) => tea.toLowerCase().includes(query.toLowerCase())));  }, [query]);   return (    <>      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search teas" />      <p>Matches: {matches.length} <RenderCount /></p>      <ul>        {matches.map((tea) => <li key={tea}>{tea}</li>)}      </ul>    </>  );}
ResultLoading…

Line 8 keeps matches in state, and the Effect on lines 10 to 12 works them out again after each new query. Line 17 shows a render counter from the RenderCount.jsx tab; it counts the renders React put on the page. Type m: the counter goes up by 2, not 1. The key renders with the old matches, the Effect runs, sets new matches, and the component renders a second time. In the Scheduler track, that second update is labelled Cascading Update: an update that started while React was still finishing the first one. React's documentation calls cascading updates out as a common cause of slow pages. Clicking the entry shows which component started it.

The fix is the one from You might not need an Effect: work out the matches while rendering. Type m here, and the counter goes up by 1:

The same search, working out matches while renderingPop out in the code editor (opens in a new tab)JSX
import { useState } from "react";import RenderCount from "./RenderCount.jsx"; const teas = ["Green tea", "Black tea", "Mint tea", "Chai"]; export default function TeaSearch() {  const [query, setQuery] = useState("");  const matches = teas.filter((tea) => tea.toLowerCase().includes(query.toLowerCase()));   return (    <>      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search teas" />      <p>Matches: {matches.length} <RenderCount /></p>      <ul>        {matches.map((tea) => <li key={tea}>{tea}</li>)}      </ul>    </>  );}
ResultLoading…

Line 8 filters the teas during the render, so each key is one render and one commit, and the Scheduler track shows no cascading update. In a production build these tracks are off. React's profiling build, from the last section, turns the Scheduler track back on, and the Components track lists the components inside a <Profiler> (or all of them while the React DevTools extension is installed).

A measure-first workflow

Each tool answers a different question. Put together, they make a short routine for any interaction that feels slow:

  1. Pick one interaction and write it down: "typing in the search box", "opening the cart".
  2. Make it realistic. Your computer is probably faster than your users'. In Chrome's Performance panel, set CPU to a 4x slowdown. For real numbers, use a production or profiling build; development numbers are only good for comparing before and after.
  3. Record only that interaction in the Profiler, with "Record why each component rendered" on.
  4. Find the slow commit and its widest bar, or the top of the ranked chart.
  5. Ask why it rendered. "The parent component rendered" and props that change on every render are the usual answers.
  6. Fix one thing, then record again and compare. If the numbers didn't move, undo the change.

Sometimes the slow part is one calculation, not a whole component. Then measure just that, with console.time and console.timeEnd. Type in the box and read the console:

Time one calculation with console.timePop out in the code editor (opens in a new tab)JSX
import { useState } from "react";import { products } from "./products.js"; export default function ProductSearch() {  const [query, setQuery] = useState("");  console.time("filter products");  const matches = products.filter((product) => product.name.includes(query));  console.timeEnd("filter products");  return (    <>      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search 5,000 teas" />      <p>{matches.length} matches</p>    </>  );}
ResultLoading…

The products.js tab makes 5,000 products. Line 6 starts a timer named filter products, line 7 filters them, and line 8 stops the timer and prints how long it ran, like filter products: 0.400 ms. Each render prints a line, two per key in development. react.dev's rule of thumb: if the logged times add up to a millisecond or more for one interaction, the calculation may be worth skipping when its inputs haven't changed. Here filtering 5,000 short names takes well under that, so leave it alone.

How to read the table: each row is a question you might have, and the tool that answers it.

Which tool answers which question
Your questionThe tool to use
Which components rendered in this interaction, and why?React DevTools' Profiler tab, with "Record why each component rendered while profiling" on.
Which components render while I use the page, without recording?React DevTools' setting "Highlight updates when components render".
How long does this part of the page take for real users?<Profiler> in a profiling build, sending its numbers to your server.
What else was the browser doing: layout, paint, network, other scripts?Chrome's Performance panel, with React's Scheduler and Components tracks.
How long does this one calculation take?console.time() and console.timeEnd() around it.

Keep the slowest commits from a real session

The DevTools Profiler shows your own session. To learn what's slow for real users, apps keep the numbers from onRender and send them to their server. This version keeps them in an array and prints the slowest three on request. Type a few letters, press Add a couple of times, then press the top button and read the table in the console:

Keep every commit and print the slowest threePop out in the code editor (opens in a new tab)JSX
import { Profiler, useState } from "react";import ProductList from "./ProductList.jsx";import Recommendations from "./Recommendations.jsx"; const commits = []; function saveCommit(id, phase, actualDuration) {  commits.push({ id, phase, ms: Number(actualDuration.toFixed(1)) });} function printSlowest() {  const slowest = [...commits].sort((a, b) => b.ms - a.ms).slice(0, 3);  console.table(slowest);} export default function ShopPage() {  const [query, setQuery] = useState("");  const [cartCount, setCartCount] = useState(0);  return (    <>      <button onClick={printSlowest}>Print the 3 slowest commits</button>      <p>Cart: {cartCount}</p>      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search teas" />      <Profiler id="ProductList" onRender={saveCommit}>        <ProductList query={query} onAdd={() => setCartCount(cartCount + 1)} />      </Profiler>      <Profiler id="Recommendations" onRender={saveCommit}>        <Recommendations />      </Profiler>    </>  );}
ResultLoading…

Line 5 is the array, outside the component, so rendering never resets it. saveCommit on lines 7 to 9 adds one row per Profiler per commit. It only stores the numbers: setting state from onRender would start another render after every render. printSlowest on lines 11 to 14 sorts a copy of the rows, slowest first, and prints three with console.table. The table's top rows all say Recommendations, the first one usually with mount. In a real app, you would use a profiling build and send rows like these with navigator.sendBeacon every so often, not after each commit.

Which tool do you open?
  • Typing in the search box feels slow, and you want to see which components render on each key
  • A sidebar renders on every key, and you want to know whether a prop changed
  • You added memo and want to check, commit by commit, that the picks are now gray
  • You want the checkout form's render times from real customers' phones
  • You want a test that fails if a change makes the cart render much more work than before
  • The page freezes after a click, and you suspect a slow layout or paint, not React
  • You want to see whether an Effect's state update starts a second render right after the first
  • You want to see when a transition's background render ran, next to the urgent ones
Try it yourself
0 of 8 correct

Sort each question by the tool that answers it best: the DevTools Profiler, the <Profiler> component, or Chrome's Performance panel.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "The component that does the obvious work is the slow one." No. In the shop, the search is quick and the picks, which don't use the query at all, take almost all the time. Record before you change anything.
  • "A wide bar in the flamegraph rendered in this commit." No. Width comes from the last render the component did. A gray bar didn't render in this commit, however wide it is.
  • "A re-render always changes the page." No. When nothing a component uses changed, React calls it, gets the same result, and changes nothing on the page. The time is still spent.
  • "'Why did this render?' shows up on its own." No. Turn on "Record why each component rendered while profiling" in the Profiler's settings before you record.
  • "onRender runs in production too." No. A normal production build never calls it. Use the profiling build, react-dom/profiling, to measure there.
  • "Development timings are what users get." No. StrictMode calls each component twice, and React runs extra checks, so development is slower. Compare before and after, and use a production or profiling build for real numbers.
  • "You need an extra library to see React in Chrome's Performance panel." No. Since React 19.2, development builds add the Scheduler and Components tracks by themselves.

Practice exercises

The shop exercises use the same files as the lesson: four teas in the ProductList.jsx tab and 15 slow picks in the Recommendations.jsx tab. The first two use the playground in Record a profile in React DevTools. Change the code with Edit & run; the result updates as you type.

Exercise 1 · Warm-upFind the widest bar

Type a letter in the playground with plain Recommendations. In the flamegraph of that commit, which component's bar is the widest in the row under ShopPage?

import { useState } from "react";
import ProductList from "./ProductList.jsx";
import Recommendations from "./Recommendations.jsx";

function CartBadge({ count }) {
  return <p>Cart: {count}</p>;
}

export default function ShopPage() {
  const [query, setQuery] = useState("");
  const [cartCount, setCartCount] = useState(0);
  return (
    <>
      <CartBadge count={cartCount} />
      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search teas" />
      <ProductList query={query} onAdd={() => setCartCount(cartCount + 1)} />
      <Recommendations />
    </>
  );
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upWhy did the cart badge render?

    You type a letter. In that commit, what does "Why did this render?" say for CartBadge? Answer first, then check in the playground: its bar is very thin in the flamegraph, so switch to Ranked and click it there.

    import { useState } from "react";
    import ProductList from "./ProductList.jsx";
    import Recommendations from "./Recommendations.jsx";
    
    function CartBadge({ count }) {
      return <p>Cart: {count}</p>;
    }
    
    export default function ShopPage() {
      const [query, setQuery] = useState("");
      const [cartCount, setCartCount] = useState(0);
      return (
        <>
          <CartBadge count={cartCount} />
          <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search teas" />
          <ProductList query={query} onAdd={() => setCartCount(cartCount + 1)} />
          <Recommendations />
        </>
      );
    }

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeMeasure the product list

      Add a <Profiler> around ProductList that logs its id, phase, and actualDuration. What phase does the first line in the console say?

      import { useState } from "react";import ProductList from "./ProductList.jsx"; export default function ShopPage() {  const [query, setQuery] = useState("");  const [cartCount, setCartCount] = useState(0);  return (    <>      <p>Cart: {cartCount}</p>      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search teas" />      <ProductList query={query} onAdd={() => setCartCount(cartCount + 1)} />    </>  );}
      ResultLoading…

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeRemove the cascading update

        Each key renders this search twice. Change it so each key renders it once. After your fix, by how much does the counter go up for each key?

        import { useEffect, useState } from "react";import RenderCount from "./RenderCount.jsx"; const teas = ["Green tea", "Black tea", "Mint tea", "Chai"]; export default function TeaSearch() {  const [query, setQuery] = useState("");  const [matches, setMatches] = useState(teas);   useEffect(() => {    setMatches(teas.filter((tea) => tea.toLowerCase().includes(query.toLowerCase())));  }, [query]);   return (    <>      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search teas" />      <p>Matches: {matches.length} <RenderCount /></p>      <ul>        {matches.map((tea) => <li key={tea}>{tea}</li>)}      </ul>    </>  );}
        ResultLoading…

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeProve that memo works, with numbers

          Wrap Recommendations in memo in its tab, then type a letter and read the Recommendations update line. Which number stays about the same: actualDuration or baseDuration?

          import { Profiler, useState } from "react";import ProductList from "./ProductList.jsx";import Recommendations from "./Recommendations.jsx"; function logRender(id, phase, actualDuration, baseDuration) {  console.log(`${id} ${phase}: actual ${actualDuration.toFixed(1)} ms, base ${baseDuration.toFixed(1)} ms`);} export default function ShopPage() {  const [query, setQuery] = useState("");  const [cartCount, setCartCount] = useState(0);  return (    <>      <p>Cart: {cartCount}</p>      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search teas" />      <Profiler id="ProductList" onRender={logRender}>        <ProductList query={query} onAdd={() => setCartCount(cartCount + 1)} />      </Profiler>      <Profiler id="Recommendations" onRender={logRender}>        <Recommendations />      </Profiler>    </>  );}
          ResultLoading…

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeKeep the slowest commit

            Fill in saveCommit so slowest always holds the slowest commit so far, as { id, phase, ms }. Right after the page loads, press the button. Which id does the slowest commit have?

            import { Profiler, useState } from "react";import ProductList from "./ProductList.jsx";import Recommendations from "./Recommendations.jsx"; let slowest = null; function saveCommit(id, phase, actualDuration) {  // Keep this commit if it's slower than the slowest one so far.} export default function ShopPage() {  const [query, setQuery] = useState("");  return (    <>      <button onClick={() => console.log(slowest)}>Print the slowest commit</button>      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search teas" />      <Profiler id="ProductList" onRender={saveCommit}>        <ProductList query={query} onAdd={() => {}} />      </Profiler>      <Profiler id="Recommendations" onRender={saveCommit}>        <Recommendations />      </Profiler>    </>  );}
            ResultLoading…

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, picture the commit: which components rendered in it, how long they took, and why.

              The profiling quiz · 8 questionsScore: first tries count
              1. Question 1 of 8In the Profiler's flamegraph, a bar is gray. What does that tell you?

                Choose an answer to see the explanation.

              2. Question 2 of 8What does a bar's width show in the flamegraph?

                Choose an answer to see the explanation.

              3. Question 3 of 8The ranked chart lists the components of one commit. How are they ordered?

                Choose an answer to see the explanation.

              4. Question 4 of 8DevTools says "The parent component rendered." for Recommendations. What does that mean?

                Choose an answer to see the explanation.

              5. Question 5 of 8With Recommendations wrapped in memo, you type a letter. What do the two durations from onRender show?

                Read the code, then predictJSX
                // Recommendations is wrapped in memo. You type one letter.
                <Profiler id="Recommendations" onRender={logRender}>
                  <Recommendations />
                </Profiler>

                Choose an answer to see the explanation.

              6. Question 6 of 8You deploy a production build with a <Profiler> in it. What does onRender do there?

                Choose an answer to see the explanation.

              7. Question 7 of 8In Chrome's Performance panel, which part of React's Scheduler track shows the second render this code starts after each key?

                Read the code, then predictJSX
                const [query, setQuery] = useState("");
                const [matches, setMatches] = useState(teas);
                
                useEffect(() => {
                  setMatches(teas.filter((tea) => tea.includes(query)));
                }, [query]);

                Choose an answer to see the explanation.

              8. Question 8 of 8Typing in a search box feels slow. What should you do first?

                Choose an answer to see the explanation.

              Key takeaways

              • Measure before you change anything: record the slow interaction, find what took the time, fix one thing, record again.
              • The DevTools Profiler shows one commit at a time. In the flamegraph, width is the last render's time with children, and gray means it didn't render. The ranked chart sorts by each component's own time.
              • Turn on "Record why each component rendered". "The parent component rendered" marks a render that changed nothing and can usually be skipped.
              • <Profiler id onRender> measures in code. Compare actualDuration with baseDuration to see skipping work. Production builds turn it off; the profiling build turns it back on.
              • Since React 19.2, Chrome's Performance panel shows React's Scheduler and Components tracks next to layout, paint, and network, including cascading updates.
              • Development numbers are slower than real ones. Use them to compare, and a production or profiling build for real timings.

              Remember the one-liner.
              Don't guess: record the slow interaction, find the widest bar, ask why it rendered, and fix that.

              Coming next: Performance patterns. It shows the fixes a profile most often points to: moving state down, passing content as children, rendering only the rows on screen, and loading code only when it's needed.

              CompleteFrontend Clear concepts. Working examples.