before optimizing, Profiler & Metrics
Wrapping everything in useMemo and useCallback does not make React apps faster, it makes them more complicated. Whoever measures first finds the real bottlenecks: unnecessary re-renders, expensive calculations in the wrong place, and layout thrashing. This article shows the correct workflow with the React Profiler, Chrome DevTools and solid metrics.
Table of Contents
- 1. Why measuring before optimizing is essential
- 2. React DevTools Profiler: basics and usage
- 3. Identifying unnecessary re-renders
- 4. React.memo: when it helps and when it hurts
- 5. useMemo for expensive calculations
- 6. useCallback: stable references for props
- 7. Reading Web Vitals and Lighthouse correctly
- 8. Long lists with virtualization
- 9. Optimization strategies compared
- 10. Summary
- 11. FAQ
1. Why measuring before optimizing is essential
The most common mistake in React performance optimization is optimizing without a data basis. Developers wrap calculations in useMemo, callbacks in useCallback, and components in React.memo because it feels like optimization. In reality, they add complexity without knowing whether the affected spots are even performance bottlenecks. The result: components that are harder to read, a higher risk of bugs from stale closures, and no measurable speed gain.
The correct workflow runs the other way around: first you measure with the React Profiler which components re-render, why, and how often. Then you check whether those re-renders actually lead to visible performance problems. Only once both questions are answered with "yes" do you reach for optimization, and afterwards you measure again to confirm it actually helped. This data-driven approach is the difference between engineering and reading tea leaves.
2. React DevTools Profiler: basics and usage
The React Profiler is built into the React DevTools browser extensions and is the most important starting point for any React performance analysis. To use it, open the DevTools, navigate to the "Profiler" tab and start a recording with the record button. Then perform the action that feels slow, a click, an input, a page navigation, and stop the recording. The Profiler then shows a flame graph of every render operation within the recorded time span.
In the flame graph, each block represents a component. Its width corresponds to the time the React reconciler spent on that component. Gray blocks mean the component was not re-rendered. Colored blocks (from yellow to red) show re-renders, with the color indicating relative render time. Especially valuable is the "Record why each component rendered" setting: it shows, per component, whether it re-rendered due to a prop change, a state change, or a context update, the most precise diagnostic aid React offers.
// Profiler API for programmatic performance measurement
import { Profiler, ProfilerOnRenderCallback } from 'react';
// Callback receives render timing data for each commit
const onRender: ProfilerOnRenderCallback = (
id, // component tree identifier
phase, // "mount" or "update"
actualDuration, // time spent rendering (ms)
baseDuration, // estimated time without memoization
startTime,
commitTime
) => {
// Log to analytics or console, only in development
if (process.env.NODE_ENV === 'development') {
console.table({ id, phase, actualDuration, baseDuration });
}
// In production: send to observability system
if (actualDuration > 16) {
analytics.track('slow_render', { id, phase, actualDuration });
}
};
// Wrap the subtree you want to measure
export const TrackedProductList = () => (
<Profiler id="ProductList" onRender={onRender}>
<ProductList />
</Profiler>
);
3. Identifying unnecessary re-renders
React re-renders a component whenever its state, its props, or a context it consumes have changed. That is expected behavior. Re-renders become a problem when they cascade into child components that have not changed at all, or when a context update renders an entire component subtree even though only a small part is actually affected by the change. The React Profiler shows exactly that: components that re-render with no recognizable reason of their own, simply because a parent re-rendered.
One especially common pattern: callback functions that are passed as a new reference on every render of the parent. Because React compares props by reference, a child component sees a "changed" prop even though the function is semantically identical. This causes the child to re-render on every render of the parent, even if the child is wrapped in React.memo. Identifying these patterns in the Profiler is the first step before deciding whether useCallback is the right fix here.
4. React.memo: when it helps and when it hurts
React.memo wraps a component and prevents its re-render if its props have not changed by reference. That sounds like an easy win, but it is not always one. The reference comparison itself carries a small overhead. For simple, fast-rendering components, this overhead is bigger than the re-render it saves. React.memo only pays off when two conditions are met at the same time: the component renders often, and the re-render is measurably expensive.
A common mistake: wrapping a component in React.memo, while the parent still passes objects or arrays created inline as props on every render, such as style={{ margin: 0 }} or items={[a, b]}. These are a new reference on every render, so React.memo will never prevent the re-render. The fix is not more memoization, but either stabilizing such props with useMemo, or restructuring the component so the data does not need to be passed as inline literals.
import React, { memo, useMemo, useCallback, useState } from 'react';
// BAD: memo is useless because items is a new array on every render
const BadExample = () => {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
{/* items is a new reference every render, memo will not help */}
<MemoizedList items={[1, 2, 3]} />
</>
);
};
// GOOD: stabilize the array with useMemo so memo can do its job
const GoodExample = () => {
const [count, setCount] = useState(0);
// items reference only changes when dependencies change
const items = useMemo(() => [1, 2, 3], []);
const handleSelect = useCallback((id: number) => {
console.log('selected', id);
}, []);
return (
<>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
{/* memo now works: items and handleSelect are stable */}
<MemoizedList items={items} onSelect={handleSelect} />
</>
);
};
// memo with custom comparison, only re-render if id changes
const MemoizedList = memo(
({ items, onSelect }: { items: number[]; onSelect: (id: number) => void }) => (
<ul>{items.map(i => <li key={i} onClick={() => onSelect(i)}>{i}</li>)}</ul>
)
);
5. useMemo for expensive calculations
useMemo caches the result of a calculation between renders and only recomputes it when its dependencies change. The decisive question is: how expensive is the calculation really? For simple operations like filtering a small array, formatting a date, or sorting ten items, the overhead of useMemo itself (dependency comparison, cache lookup) is often bigger than the calculation it saves. useMemo measurably pays off for operations that cost more than 1 to 2 ms in the Profiler, or for calculations that produce a new object reference which is then passed on as a prop.
An especially important use case: context values. If a context provider supplies its value as an object literal, such as value={{ user, login, logout }}, that value is a new reference on every render of the provider, and every consumer re-renders regardless of whether user, login or logout actually changed. The correct pattern is useMemo(() => ({ user, login, logout }), [user, login, logout]). The context then only triggers re-renders when a value has actually changed, not on every re-render of the provider's parent.
6. useCallback: stable references for props
useCallback returns the same function reference as long as its dependencies do not change. Its primary benefit is making callbacks stable so memoized child components do not re-render unnecessarily. Without useCallback, every render of the parent creates a new function reference, and React.memo on the child then fails to prevent the re-render. With useCallback, the reference stays stable and React.memo can do its job.
An advanced use case is stabilizing callbacks for useEffect. If a callback sits in the dependency list of a useEffect and changes on every render, the effect runs on every render, which is almost always a bug. useCallback stabilizes the reference and makes the effect deterministic. Alternatively, since React 18 the pattern with useEffectEvent (stable as of React 19) is recommended, which removes the callback from the dependency list entirely so the effect only reacts to dependencies that are actually relevant.
7. Reading Web Vitals and Lighthouse correctly
Web Vitals are user-centric metrics that correlate directly with perceived performance. LCP (Largest Contentful Paint) measures when the largest visible element has loaded, target under 2.5 seconds. INP (Interaction to Next Paint) replaced FID and measures the time between a user interaction and the next frame, target under 200ms. INP is the metric most directly affected by poor React performance: when a click handler triggers a long synchronous render, INP spikes immediately. CLS (Cumulative Layout Shift) measures visual instability, images without dimensions or asynchronously loaded content without placeholders are the most common causes.
Lighthouse and the Chrome DevTools Performance tab offer two different perspectives. Lighthouse simulates a slower browser and produces a single score, useful for comparing before and after optimizations. The Performance tab records the actual browser thread and shows where long tasks block the main thread. Long tasks over 50ms are the direct enemy of a good INP score. In React, they typically arise from synchronous state updates that re-render many components at once. React 18's startTransition marks non-urgent updates as low priority and lets the browser process user interactions in between.
8. Long lists with virtualization
Rendering thousands of DOM elements is one of the few React performance problems that no memoization can fix. The DOM has a fundamental cost function: every element consumes layout time, paint time, and memory. A list with 10,000 products renders 10,000 DOM nodes even if the user only sees 20 of them. Virtualization solves this by rendering only the visible items and replacing the rest with placeholders. As the user scrolls, elements that become invisible are removed and newly visible ones are created.
The recommended libraries are @tanstack/react-virtual (headless, full control over markup) and react-window (simpler, but less flexible). For more complex requirements like variable item heights, horizontal scrolling, or grouping, react-virtuoso offers the best developer experience. Important: virtualization has overhead of its own, for lists under 100 items it is counterproductive. The Profiler shows whether the list's render time is actually a problem before introducing virtualization.
// Virtualizing a large product list with @tanstack/react-virtual
import React, { useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
interface Product { id: string; name: string; price: number; }
interface VirtualProductListProps {
products: Product[];
}
export const VirtualProductList: React.FC<VirtualProductListProps> = ({ products }) => {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: products.length,
getScrollElement: () => parentRef.current,
// Provide estimated height, virtualizer corrects after first render
estimateSize: () => 80,
overscan: 5, // render 5 extra items above/below viewport
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
{/* Total scrollable height, maintains correct scrollbar */}
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map(virtualItem => (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
<ProductCard product={products[virtualItem.index]} />
</div>
))}
</div>
</div>
);
};
9. Optimization strategies compared
Not every performance optimization helps equally. Choosing the right strategy depends on what the Profiler has identified as the bottleneck. Blindly applying every optimization at once makes the code harder to maintain, with no guaranteed benefit.
| Problem (Profiler shows...) | Wrong reaction | Correct strategy | Measurement |
|---|---|---|---|
| Child component renders often | memo without stable props | useCallback + memo together | Profiler: actualDuration drops |
| Context triggers many re-renders | Inline object in the provider | useMemo for the context value | Profiler: fewer render commits |
| List with 1000+ items | memo on every list item | Virtualization (react-virtual) | DevTools: DOM node count |
| INP > 200ms after a click | useMemo everywhere | startTransition for updates | Web Vitals: INP score |
| Expensive initial load | Memoize everything | Code splitting + lazy() | Lighthouse: LCP score |
The most important takeaway from the table: there is no universal optimization. Each strategy addresses a specific problem that the Profiler must have identified beforehand. Applying all strategies at once produces complex code that is harder to test and debug, without necessarily being any faster. The workflow is always: measure, identify the problem, choose a strategy, implement the optimization, measure again.
Mironsoft
React performance analysis and targeted optimization
Does your React app feel slow?
We run a data-driven performance analysis of your React application, using the Profiler, Web Vitals and Chrome DevTools, and deliver prioritized optimization measures with measurable results.
Profiler analysis
Identifying re-render cascades, expensive calculations, and context overhead
Targeted optimization
memo, useMemo, useCallback and startTransition exactly where they help
Web Vitals improvement
Improving INP, LCP and CLS, with before/after measurements as proof
10. Summary
The most important principle of React performance optimization is: measure first, optimize second. The React Profiler in the DevTools shows which components re-render, why they do so, and how long it takes. React.memo only pays off with stable prop references, which is exactly what useCallback provides for functions and useMemo for objects and arrays. Context values need to be stabilized with useMemo so that not every provider render re-renders every consumer. Long lists need virtualization, not memoization. Web Vitals, especially INP, are the only user-centric confirmation that an optimization actually helped.
The most common misinvestment in React performance is applying useMemo and useCallback everywhere before the Profiler has even been opened. Both APIs carry a small overhead of their own and make code harder to read. They are tools for specific, measured problems, not general preventive measures. Teams that work with the Profiler regularly find that their supposed performance problems concentrate on two or three concrete spots that can be fixed quickly with targeted measures.
React Performance, the key points at a glance
Profiler first
Record with the React DevTools Profiler and "Record why each component rendered", find bottlenecks before optimizing.
memo + useCallback together
React.memo without stable prop references (useCallback/useMemo) achieves nothing, both need to be used together.
Stabilize context values
Inline objects in the provider create new references on every render. useMemo for context values prevents mass re-renders.
INP as the guiding metric
Interaction to Next Paint under 200ms, startTransition for non-urgent updates prevents long tasks on the main thread.