React performance
covers profiling with the Profiler · memo/useMemo economics · context splitting · the React Compiler · virtualization & rescheduling
Profile before you change one line
Guessing which component is slow is how you end up wrapping the whole app in memo and fixing nothing. The React DevTools Profiler measures it for you. Record a session, do the slow interaction (type one character in the box that lags), stop.
The commit bar chart across the top is your timeline. Each bar is one commit, meaning one time React applied changes to the DOM, and its height and color track how long that commit took: tall yellow bars are the expensive ones. Click the worst one.
Now the flamegraph shows that single commit. Each bar is a component; its width is how long that component last took to render, and its color is how much of this commit it and its children cost. Gray means it did not render this commit at all. The ranked chart is the same data sorted slowest first, which is quicker when you just want the one hot component.
The setting that pays for itself: open the Profiler settings (the gear) and turn on "Record why each component rendered while profiling". Record again, select a component, and the side panel tells you it rendered because its props changed, its hooks changed, its parent rendered, or its context changed. That one line is usually the whole diagnosis.
Why a memo boundary can do nothing
memo(Component) skips a re-render only when every prop is unchanged under a shallow Object.is check. Object.is treats two different object references as different even when their contents match, so a single inline prop defeats the entire boundary:
// Row is memoized, but this re-renders it on every parent render:
<Row data={row} onSelect={() => select(row.id)} />
// onSelect is a brand-new function each render, so the prop check fails
Wrong "It is wrapped in memo, so it will not re-render." memo compares props, and a fresh inline object, array, or arrow fails that compare every time. Right stabilize the reference (useCallback for the handler, or lift it out of the component so it never changes), or drop the wrapper and pass the primitive the child actually reads. The Profiler's "why did this render" names the offending prop, so you do not have to hunt.
Two cases memo never covers, no matter how you write the props: a component re-renders when its own state changes, and when a context it reads changes. memo only blocks re-renders pushed down from a parent.
How one context value re-renders your whole tree
When a provider's value changes, React re-renders every component that reads that context, wherever it sits below the provider. That is the intended behavior. It turns into a performance bug when the value is a new object on every render:
// new object literal every render, so every consumer re-renders every time
<AppContext value={{ user, filters, setFilters }}>
Chart reads only user, yet it re-renders on every keystroke that changes filters, because the whole value object is new and memo cannot stop a context-driven re-render. Two fixes:
// 1. stabilize the value so it only changes when its parts change
const value = useMemo(() => ({ user, filters, setFilters }), [user, filters]);
// 2. stronger: split by what changes. Chart subscribes to user alone.
<UserContext value={user}>
<FiltersContext value={filtersValue}>{children}</FiltersContext>
</UserContext>
Splitting is usually the better move, because it stops the fast-changing filters from ever reaching the components that only need user.
When memo and useMemo are worth it
memo and useMemo are bets: you pay a comparison and some memory on every render to sometimes skip work. The bet loses when the component was cheap anyway, when a prop is always new, or when no memoized child ever consumes the reference you stabilized.
useMemo caches a calculation. React's own guidance is to time it with console.time, and if the calculation runs under about 1ms it is probably not worth memoizing. As the docs put it, unless you are creating or looping over thousands of objects, it is probably not expensive. And memoization is not a guarantee: React can throw the cache away (it does in development when you edit the file, and when a component suspends on its first mount), so never lean on useMemo for correctness, only for speed.
The sequence that works: profile, colocate state so the slow interaction re-renders a small subtree, split context, pass children through, and only then memo the one boundary the Profiler flagged. Reach for memo first and you paper over a tree shape that the structural moves would have fixed for free.
Reading actualDuration against baseDuration
The DevTools flamegraph tells you where a commit spent its time; the <Profiler> component tells you whether your memoization is doing anything, in numbers you can log in CI or over a real session. Its onRender callback hands you actualDuration (what this update actually cost) and baseDuration (what re-rendering the whole subtree with no memoization would cost, summed from each component's last render):
<Profiler id="Dashboard" onRender={(id, phase, actualDuration, baseDuration) => {
// actualDuration near baseDuration => memoization is skipping nothing
report(id, phase, actualDuration, baseDuration);
}}>
<Dashboard />
</Profiler>
When actualDuration sits close to baseDuration, the subtree is re-rendering as if you never memoized it, which is the signal that a boundary is broken (an unstable prop) or that the re-render arrives through context or own state, where memo has no say. Profiling adds overhead, so React disables it in the production bundle by default; you opt into a special profiling build to measure production behavior. In the DevTools UI the same finding shows up as yellow deep in a subtree that should have been gray, plus the "record why each component rendered" attribution. Attribute the render first, then fix.
Splitting context by change frequency
Context has no partial subscription: a provider notifies every consumer on every value change, and a consumer that reads one field of the value still re-renders when any other field changes. Two levers follow from that.
Keep the value reference stable. A value built inline (value={{ ... }}) is a new object each render, so consumers re-render even when the underlying data did not move. useMemo the object and useCallback the functions it carries, so the reference changes only when the data does.
Then split the context by how often each part changes. A single provider carrying a rarely-changing user and a per-keystroke filters drags every user-only consumer through each filter change. Separate providers, one per concern, let a component subscribe to only what it reads:
// changes rarely | changes on every keystroke
<UserContext value={user}>
<FiltersContext value={filtersValue}>{children}</FiltersContext>
</UserContext>
Wrong "One context is cleaner, fewer providers to manage." Fewer providers means a wider blast radius: every consumer pays for every change to any field. The cost is not the provider count, it is the subscriber count times the change rate. For a value that changes very often and is read very widely (a live cursor position, a scroll offset), even split contexts re-render too much, and the right tool is an external store read through useSyncExternalStore with a selector so each component re-renders only when its slice changes (react-concurrent owns that primitive).
What the React Compiler does and does not do
The React Compiler is a build-time tool that inserts memoization for you from its own analysis, understanding the Rules of React so you do not rewrite code to adopt it. In a codebase that enables it and follows the rules, it applies the equivalent of memo, useMemo, and useCallback automatically and skips cascading re-renders, so most hand-written memoization becomes redundant and can be deleted. It is stable and has been used in production at scale. The broken-boundary bug largely stops mattering, because the compiler memoizes the inline prop too.
The interview point is the boundary of that help: the compiler optimizes memoization, it does not redesign your app. It will not split a context, so a value that genuinely changes on every render still re-renders every consumer, and that is correct behavior rather than waste the compiler could strip. It will not virtualize a 10,000-row list, so a commit that is large because the work is large stays large. It will not reorder a data-fetching waterfall. The shape of your component tree and your data flow is exactly what memoization cannot fix, and the compiler only automates memoization. Turning it on changes where measurement starts, not whether you measure.
When the work is genuinely unavoidable
Some renders are slow because they do real work, and no memoization removes work that has to happen: 10,000 rows is 10,000 rows. Two moves remain. Do less: virtualize so the DOM holds the visible window instead of the whole list, or paginate, or filter server-side (rendering-performance owns the windowing mechanics and the accessibility tradeoffs). Or reschedule: mark the non-urgent update with a transition or a deferred value so React does the work interruptibly, off the path of the keystroke echo (react-concurrent owns those semantics).
memo decides whether a render happens; virtualization and rescheduling decide how much unavoidable work runs, and when. The Profiler tells you which you need: many small re-renders of the same subtree on one interaction points at structure and memoization, while a single fat commit points at virtualization or a transition. Fix what the trace shows rather than what you reached for first.
dig deeper
Primary sources behind these notes - the specs and official docs worth reading in full.
gapmap pro
You've read the React performance notes. An interviewer will ask you to prove them.
Know you're ready. Don't hope.
The notes are free: every topic, senior depth. Pro is what turns reading into readiness you can prove:
Mock interviews on your topics
An AI interviewer that pushes back with follow-ups and grades you honestly. Per module, fair-use unlimited.
Voice test interviews
The dress rehearsal, out loud: questions from your map, a transcript, and a per-answer verdict.
A plan to your date
Ready or not ready, per module, as the interview approaches. Recomposed whenever your goal changes.
A mentor inside every lesson
Stuck on the senior notes? Ask, drill deeper, get re-quizzed on the spot.
Pro $20/mo · Pro+ $50/mo · every study note on the site stays free