React Performance Profiler: Finding and Fixing Slow Components
Learn how to use the React DevTools Profiler to pinpoint slow renders, fix wasted re-renders, and ship snappy UIs — with real code examples and actionable fixes.
Why Your React App Feels Slow (And How to Actually Find Out)
Guessing at performance problems is how you waste an afternoon. You'd optimize a component that renders once, miss the one that renders 80 times per keystroke, and ship nothing useful. The React DevTools Profiler — introduced properly in React 16.5 — exists to stop that guesswork dead.
The Profiler records exactly which components rendered, how long each one took in milliseconds, and — this is the part people miss — *why* they rendered. You get a flame chart, a ranked list sorted by render time, and per-interaction timings. That's not a nice-to-have. That's the whole game.
Honestly, most React performance issues fall into three buckets: components re-rendering when their props haven't changed, expensive computations happening on every render, and state living too high in the tree so trivial updates cascade down 12 levels. The Profiler tells you which bucket you're in within about 90 seconds of opening it.
Worth noting: the Profiler only runs in development builds. Production React strips it out for bundle-size reasons. If you need production profiling, React 18 added enableSchedulingProfiler and you can use the Profiler API programmatically — but for most debugging sessions, the DevTools version is all you need.
Setting Up and Recording Your First Profile
Install the React DevTools browser extension (Chrome or Firefox). Open your app in development mode, open DevTools, click the "Profiler" tab, and hit the record button — the red circle in the top-left. Interact with the slow part of your UI, then stop the recording. That's it.
The flame chart you get back shows each component as a colored bar. Grey means it didn't render during this recording. Yellow or orange means it did render, and the width of the bar maps to how long it took. A bar that's 16ms or wider is already causing you to drop frames at 60fps — you want most renders under 5ms.
// You can also profile programmatically with the Profiler component
import { Profiler } from 'react';
function onRenderCallback(id, phase, actualDuration) {
// id = the "id" prop of the Profiler
// phase = 'mount' | 'update' | 'nested-update'
// actualDuration = time in ms for this render
if (actualDuration > 10) {
console.warn(`Slow render in ${id}: ${actualDuration.toFixed(2)}ms (${phase})`);
}
}
export function App() {
return (
<Profiler id="App" onRender={onRenderCallback}>
<YourSlowComponent />
</Profiler>
);
}The ranked chart view is underrated. Switch to it with the bar-chart icon next to the flame chart toggle. It sorts components by self-render time — the time spent in that component's render function excluding children. That's usually where the real culprit hides.
One more thing — enable "Record why each component rendered" in the Profiler settings (the gear icon). This surfaces the actual reason: a prop change, a state change, or a context update. Without this you're still half-blind.
The Three Fixes That Cover 90% of Cases
Once the Profiler fingers a specific component, you usually need one of three tools: React.memo, useMemo, or useCallback. They're not magic — applied wrong they make things worse — but applied right they eliminate entire categories of wasted work.
React.memo wraps a component and skips its re-render if props are shallowly equal to the previous render. This is your first move when the Profiler shows a child component re-rendering constantly but its props haven't visually changed. Don't wrap everything — only components that render often and are expensive.
// Before: re-renders whenever the parent renders
function ProductCard({ name, price }: { name: string; price: number }) {
return (
<div className="card">
<h2>{name}</h2>
<p>${price}</p>
</div>
);
}
// After: skips re-render if name and price are unchanged
const ProductCard = React.memo(function ProductCard({
name,
price,
}: {
name: string;
price: number;
}) {
return (
<div className="card">
<h2>{name}</h2>
<p>${price}</p>
</div>
);
});useMemo memoizes a computed value. If you're deriving a filtered list, sorting 500 items, or doing any non-trivial calculation during render, wrap it: const sorted = useMemo(() => items.sort(byDate), [items]). The Profiler will show the component's render time drop from, say, 34ms to 2ms after this change. That's real.
useCallback memoizes a function reference so it doesn't change identity on every render. This matters when you're passing callbacks as props to memoized children — if the function is recreated each render, React.memo on the child does nothing because props are technically different. Check out the React useMemo and useCallback deep-dive for the full story on when each one is worth using.
Context: The Hidden Performance Killer
The Profiler will sometimes show you a component re-rendering and the reason will be "Context changed." This is where developers get blindsided. In React 18, any component that calls useContext(MyContext) re-renders whenever *any value* in that context object changes — even if the component only cares about one field out of twenty.
Look, this is the most common source of slow UIs in apps that grew organically from a simple context setup. You add one more field to your global context object, and suddenly your entire component tree is re-rendering on every keystroke in a search box somewhere.
// Problematic: one context, all components re-render when anything changes
const AppContext = createContext<{ user: User; theme: string; cart: CartItem[] }>(null!);
// Better: split contexts by update frequency
const UserContext = createContext<User>(null!);
const ThemeContext = createContext<string>('light');
const CartContext = createContext<CartItem[]>([]);
// Components subscribe only to what they need
function CartIcon() {
const cart = useContext(CartContext); // only re-renders on cart changes
return <span>{cart.length}</span>;
}Splitting contexts by update domain is the first fix. The second is memoizing the context value itself with useMemo so object identity stays stable when the values haven't actually changed. If you're finding context isn't enough, Zustand gives you fine-grained subscriptions where components only re-render when the specific slice they subscribe to changes.
Quick aside: React 19 introduced the "use" hook and further improvements to server components that can push more work off the client entirely. If you're stuck on React 18 for now, splitting contexts and using Zustand buys you most of the same gains.
Profiling Component-Heavy UI Libraries
If you're running a design-heavy UI — glassmorphism cards, animated backgrounds, complex layered layouts — the performance story gets more interesting. More components, more renders, more potential for cascade. The Profiler becomes even more valuable here.
When building with Empire UI components, you're starting from a solid base — each component is already optimized for render efficiency, with stable prop interfaces that play nicely with React.memo. That said, the *way* you compose them still matters. Dropping 200 card components into a flat list with no virtualization will kill performance regardless of how fast each individual card renders.
For long lists, pair virtualization with the Profiler. Install @tanstack/react-virtual (or react-window if you're on an older stack), record a scroll interaction in the Profiler, and verify that only the visible window of items appears in the flame chart. If you see 200 items in the chart during a scroll, virtualization isn't wired up correctly.
// Basic virtualization with @tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualCardList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 120, // estimated card height in px
});
return (
<div ref={parentRef} style={{ height: '600px', overflowY: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{ transform: `translateY(${virtualItem.start}px)` }}
>
<ItemCard item={items[virtualItem.index]} />
</div>
))}
</div>
</div>
);
}In practice, the biggest wins in component-heavy UIs come from two places: cutting the number of renders through memoization, and cutting the cost of each render through virtualization. The Profiler tells you which lever to pull first.
Reading Commit Timings and Interaction Traces
The Profiler shows "commits" — each time React actually applied changes to the DOM. A commit at 8ms is fine. A commit at 60ms is causing a perceptible freeze. A commit at 200ms means something is seriously wrong and your users are already annoyed.
What causes long commits? Usually: a component doing synchronous expensive work during render (filtering 10,000 items inline, running a regex on every keystroke), layout effects that trigger reflows, or an initial mount with a massive component tree that hasn't been code-split. Each of these has a distinct Profiler signature.
The interaction tracing view (click "Interactions" tab in the Profiler) connects user events to render commits. This is how you answer "what happens when I type in this search field?" — you see exactly which components re-rendered in response, and how many commits that single keystroke triggered. If a keystroke causes 8 separate commits, you've got a state management problem that useDeferredValue or batching can fix.
// useDeferredValue defers expensive re-renders to keep the input snappy
import { useDeferredValue, useState } from 'react';
function SearchPage({ items }: { items: Item[] }) {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
// This expensive filter runs on the deferred value
// so the <input> stays responsive at 60fps
const filtered = useMemo(
() => items.filter((i) => i.name.toLowerCase().includes(deferredQuery)),
[items, deferredQuery]
);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ResultsList items={filtered} />
</>
);
}Worth noting: useDeferredValue was added in React 18 and it's one of the best additions to the API in years. It lets React prioritize the input update over the expensive downstream work, so users see their typing reflected immediately even while the filtered list catches up. The Profiler will show two commits per keystroke — a fast one for the input, a slower deferred one for the results — which is exactly right.
When Profiling Points at Third-Party Components
Sometimes the flame chart incriminates a component you didn't write. A charting library, a date picker, a rich text editor — they show up as slow bars and you can't just slap React.memo on them from the outside.
Your options: lazy-load them with React.lazy and Suspense so the initial mount cost doesn't block your critical path, wrap them in a container component that aggressively gates re-renders via React.memo with a custom comparator, or replace them with a lighter alternative. The headless UI comparison guide is worth reading if you're evaluating swaps for heavy component libraries.
// Lazy-load a heavy third-party component
const HeavyChart = React.lazy(() => import('./HeavyChart'));
function Dashboard() {
return (
<Suspense fallback={<div className="skeleton h-64 w-full rounded-xl" />}>
<HeavyChart data={chartData} />
</Suspense>
);
}The skeleton loader pattern pairs directly with lazy loading here — while the heavy component downloads and mounts, users see a placeholder that matches the layout so the page doesn't feel broken. That combination — lazy load plus skeleton — turns a 340ms blocking mount into a perceived instant render.
One more thing — if you're building UI with visual flair (animations, effects, layered graphics) and performance is a concern, consider browsing Empire UI's component library to see if a ready-built, optimized component covers your use case. Hand-rolling complex animated components and then profiling them is fine as a learning exercise, but it's not the fastest path to shipping.
FAQ
Not by default — React strips profiling code in production builds for performance reasons. You can opt back in by using react-dom/profiling and scheduler/tracing builds, or instrument specific components with the <Profiler> API and send timings to your analytics pipeline.
Self time is how long the component's own render function took, excluding children. Total time includes all descendants. Always sort by self time first — it points at the actual bottleneck rather than a parent that's slow only because its children are.
Use useMemo to memoize a computed value (filtered list, derived data). Use useCallback to memoize a function reference so it doesn't break React.memo on child components that receive it as a prop. Don't use either one preemptively — let the Profiler show you a real problem first.
Yes. Run next dev and open the Profiler tab in React DevTools — it works on client components exactly as it would in a plain React app. Server components don't appear in the Profiler since they render on the server, but their client children do.