Measure Transition Performance in React
Marking updates as transitions is only half the battle. To truly optimize your app, you must measure whether your transitions are actually helping. React DevTools Profiler is the primary tool for understanding render times, identifying components that re-render unnecessarily, and confirming that your transitions are having the intended effect. In this article, you'll learn to profile concurrent rendering, interpret the data, and spot performance bottlenecks.
Profiling is not optional. Without it, you're guessing. With it, you'll see exactly where your app spends time and optimize with confidence.
Opening the React DevTools Profiler
The React DevTools Profiler is a browser extension. Install it, then:
- Open your React app in the browser.
- Open DevTools (
F12orCtrl+Shift+I). - Click the "Profiler" tab (you may need to scroll through the tabs to find it).
- Click the red circle (record button) to start profiling.
- Interact with your component (type in a search, click a button, etc.).
- Click the red circle again to stop recording.
React will show you a detailed breakdown of every render that happened during your interaction.
Reading a Profiler Trace
Here's what a profiler trace shows for a simple search component:
Render: SearchResults (14 ms)
└─ Filter Computation (12 ms)
└─ List Re-render (2 ms)
Commit: (1 ms)
The numbers tell the story:
- Render: The time React spent preparing the update (checking which components changed, calling render functions).
- Commit: The time React spent applying changes to the DOM.
- Individual component times: How long each component's render function took.
A transition render often appears as two separate renders in the trace:
- High-priority render (fast, e.g., 0.5 ms) — updates the input field.
- Transition render (slow, e.g., 150 ms) — filters the list.
If you see only one slow render, your transition isn't working correctly.
Real Example: Profiling a Filterable Search
Let's profile the filterable search component from earlier:
import { useState, useTransition, useMemo } from 'react';
export function ProfiledFilterSearch() {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
const allProducts = useMemo(() => generateProducts(100000), []);
const filtered = useMemo(() => {
return allProducts.filter(p =>
p.name.toLowerCase().includes(query.toLowerCase())
);
}, [allProducts, query]);
const handleChange = (e) => {
const newQuery = e.target.value;
setQuery(newQuery);
// In a real profiling session, this startTransition wrapper
// would not exist. Remove it to see the difference.
startTransition(() => {});
};
return (
<div>
<input
value={query}
onChange={handleChange}
placeholder="Type to search..."
/>
{isPending && <p>Filtering...</p>}
<ul>
{filtered.slice(0, 20).map(p => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</div>
);
}
function generateProducts(count) {
return Array.from({ length: count }, (_, i) => ({
id: i,
name: `Product ${i}`,
}));
}
Now profile it:
- Open DevTools Profiler.
- Click record.
- Type "hello" in the input field slowly, one character at a time.
- Stop recording.
Without useTransition, the profiler shows:
- Every keystroke triggers a render taking 50–100 ms.
- The input lags noticeably because React is blocked computing the filter.
With useTransition (correctly implemented), you'd see:
- Input keystroke → fast render (1–5 ms).
- Filter computation → separate render starting a moment later (50–100 ms).
- If you type again before the filter finishes, only the new filter render starts; the old one is abandoned.
Flame Chart Deep Dive
The Profiler shows a flame chart. Wider bars = longer time. Taller stacks = more components. Here's what to look for:
ComponentA ████████ (40 ms)
└─ ComponentB ██████ (35 ms)
│ └─ ComponentC ██ (2 ms)
│ └─ ComponentD ████ (33 ms)
└─ ComponentE (5 ms)
In this example, ComponentD is the bottleneck—it's taking 33 ms to render. If it's rendering unnecessarily, you can optimize it by:
- Wrapping it in
React.memoto prevent re-renders when props haven't changed. - Splitting it into smaller components.
- Using
useMemoto avoid recomputing expensive logic.
Detecting Unnecessary Re-Renders
The Profiler shows why each component re-rendered. If a component re-rendered but its props didn't change, that's a wasted render. Example output:
ComponentA rendered because:
└─ Props changed: (no changes)
└─ State changed: query
└─ Hook dependency changed: [query]
No changes means the parent re-rendered but didn't pass new props. Wrap this component in React.memo:
const OptimizedComponentA = React.memo(function ComponentA({ data }) {
return <div>{data}</div>;
});
Measuring Specific Metrics
Use React's Profiler API to measure render times programmatically:
import { Profiler } from 'react';
function onRenderCallback(
id, // Component ID
phase, // "mount" or "update"
actualDuration, // Time for this render
baseDuration, // Time without memoization
startTime, // When React started rendering
commitTime, // When React committed
interactions // Set of interactions that triggered the update
) {
console.log(`${id} (${phase}): ${actualDuration}ms`);
}
export function App() {
return (
<Profiler id="SearchComponent" onRender={onRenderCallback}>
<FilterSearch />
</Profiler>
);
}
Console will log:
SearchComponent (update): 145ms
SearchComponent (update): 3ms
The first is the transition render (slow), the second is the input render (fast).
Comparing Before and After
Always measure before and after optimizing. Here's a structured approach:
- Baseline: Profile the component in its current state. Note the render times.
- Hypothesize: Guess which component is slow.
- Optimize: Apply a fix (add
memo, useuseMemo, mark as transition). - Re-profile: Measure the same interaction again.
- Validate: Did the render time improve? By how much?
Example improvements:
- Without
useTransitionon a 100k-item filter: input lags, render takes 150 ms. - With
useTransition: input is instant, transition render still takes 150 ms but doesn't block the input.
The absolute render time didn't change, but the perceived performance improved dramatically because the input is responsive.
Common Performance Issues and Fixes
| Problem | Cause | Fix |
|---|---|---|
| Component re-renders on every keystroke | Parent re-renders unnecessarily | Wrap in React.memo, or lift state up |
| Transition render is slow (>200 ms) | Expensive computation in render | Move to useMemo, or split into smaller renders |
| Input lag even with transition | Incorrect startTransition placement | Ensure high-priority state update is outside startTransition |
| Profiler shows many re-renders | Hook dependencies change frequently | Review dependency arrays in useEffect, useMemo, useCallback |
Profiling in Production
Development builds are slow. For accurate measurements, profile in a production build:
npm run build
npm run serve
Production builds are much faster, so a render taking 50 ms in development might take 5 ms in production. Always profile in production to get realistic numbers.
Key Takeaways
- Use React DevTools Profiler to measure render times and identify bottlenecks.
- A correctly implemented transition shows two renders: a fast high-priority render and a deferred slow render.
- Look for components that re-render unnecessarily (no prop changes) and wrap them in
React.memo. - Measure before and after optimizing to confirm improvements.
- Profile in production builds for realistic performance data.
Frequently Asked Questions
Why does the Profiler show two renders when I type once?
The first render is the high-priority state update (input), the second is the deferred transition (filter). If you see only one slow render, your transition isn't working.
Is a 100 ms render time bad?
Not necessarily. If it's a transition (low-priority) and the UI stays responsive, it's fine. If it's blocking the input, it's bad.
Can I profile in production without slowing down users?
Use conditional profiling: only enable the Profiler component when a URL parameter is set (e.g., ?profiling=true). Production builds won't show the Profiler overhead to regular users.
How do I know if React.memo is helping?
Profile before and after adding React.memo. If the component renders fewer times or faster, it's helping. If not, the prop is probably changing.