114: Performance, React Compiler, Transitions, Deferred Values, and Activity
Learning objectives
By the end of this lesson, you should be able to:
- distinguish React render cost from DOM, layout, and network cost;
- profile a problem before choosing an optimization;
- explain what
memo,useMemo, anduseCallbackactually do; - understand how the stable React Compiler changes the role of manual memoization;
- use
useTransitionandstartTransitionfor non-urgent updates; - use
useDeferredValuewhen a consumer can safely lag behind its source value; - reason about rendering that React may interrupt;
- use Activity when preserving the state of hidden UI is worthwhile;
- reduce bundle and rendering cost through architecture; and
- avoid optimization folklore that is not supported by measurements.
Performance is a category problem
When somebody says that a React application is slow, React rendering is only one possible cause. The actual bottleneck may be:
- network latency;
- an oversized JavaScript bundle;
- image decoding;
- layout thrashing;
- an expensive render calculation;
- too many DOM nodes;
- a state update high in the component tree;
- repeated parsing;
- memory pressure; or
- unnecessary Effects.
That distinction matters because a memoization Hook can address only some problems involving render work. It will not make an API respond faster, reduce the cost of decoding a large image, or repair a layout that forces the browser to do excessive work.
The first debugging question is not “Which optimization Hook should I add?” It is “Which system is spending the time?” JavaScript can be slow before React renders, React can be slow while calculating the next tree, the commit can expose expensive DOM work, and the browser can be slow after the commit. A request or an Effect can also make an interaction feel slow without being part of React's render phase.
Measure first
Start with evidence. Useful tools include:
- the React DevTools Profiler;
- the browser Performance panel;
- the Network panel;
- a production build; and
- realistic device and network throttling.
For a repeatable investigation, record the interaction you performed, the commit duration, the expensive component, any browser long tasks, layout and paint cost, and the number of transferred bytes.
Without a baseline, optimization is guesswork. A change can make code look more sophisticated while having no measurable effect, or while moving the cost to a different part of the system.
Repeat the same interaction before and after the change. Record enough context to make the comparison meaningful: build mode, browser, viewport, data size, and throttling settings. If the result changes only on your machine or only with development tooling enabled, it is not yet reliable evidence for a production decision.
State locality
The most valuable React performance change is often architectural rather than a memoization trick. State should generally live as close as practical to the code that consumes it.
Consider a page where only the search feature needs the current draft:
function App() {
const [draft, setDraft] =
useState('');
return (
<>
<HugeDashboard />
<SearchInput
draft={draft}
setDraft={
setDraft
}
/>
</>
);
}
Move the draft state down into the search feature if the rest of the page does not need to observe every keystroke. Then typing no longer requires the highest-level application component to recalculate everything below it.
This is usually a better first move than adding memo to every descendant. It reduces the amount of work that is scheduled in the first place, instead of trying to make a large tree repeatedly prove that its props did not change.
State locality also clarifies ownership. If another region eventually needs the value, lift it only to the nearest common owner that needs it. Avoid moving rapidly changing state to the application root merely because the root is convenient; doing so increases the area affected by each update.
memo
memo can skip a component render when the component receives props that compare equal to the previous props:
const TaskRow =
memo(
function TaskRow({
task,
onToggle,
}) {
return (
<li>
<button
onClick={() =>
onToggle(
task.id,
)
}
>
{task.title}
</button>
</li>
);
},
);
The useful distinction is that memo is an optimization, not a correctness mechanism. The component must still behave correctly when it renders normally.
If task or onToggle receives a new identity on every parent render, the comparison may fail every time and the memo boundary will skip nothing. Profile before adding the boundary, and then verify that its inputs are stable enough for it to help.
The default comparison is based on prop identity for objects and functions, not on a deep comparison of their contents. A newly created object with the same fields is still a different prop value. That is why changing state ownership or avoiding unnecessary object creation can be more effective than adding a custom comparator.
useMemo
useMemo caches the result of a calculation until one of its dependencies changes:
const visibleTasks =
useMemo(
() =>
expensiveFilter(
tasks,
query,
),
[tasks, query],
);
Use it when the calculation is expensive enough that caching has a measurable benefit, or when the stable identity of the result is required by another optimized boundary. The dependency list must describe the values that affect the calculation.
Do not wrap trivial arithmetic in useMemo by reflex. The cache itself has bookkeeping and makes the code harder to read. A simple calculation is often clearer and fast enough without it.
useCallback
useCallback caches a function identity:
const handleToggle =
useCallback(
(id) => {
dispatch({
type: 'toggled',
id,
});
},
[dispatch],
);
It does not make the function body execute faster. Its purpose is to keep the function identity stable when that identity matters to an optimized child or to a dependency boundary.
As with useMemo, use it for a demonstrated reason. A callback that is passed to a normal, inexpensive child usually does not need this extra layer of caching.
The dependency list is part of the behavior, not a performance suggestion. If the callback reads a changing value, that value belongs in the dependency list unless the code uses a different, correct pattern for accessing it. Hiding a dependency to preserve identity can create stale behavior.
React Compiler
The stable React Compiler can automatically optimize component and value reuse at build time. That changes the default optimization mindset:
purity first
state design second
profile
compiler where enabled
manual memoization when justified
Do not teach or adopt either of these as default React style:
every callback should use useCallback
or:
every calculation should use useMemo
Compiler effectiveness depends on code following React's rules and purity expectations. The compiler is not a replacement for good state ownership or measurement, and it does not make impure code safe.
Manual memoization can still be appropriate when a measured boundary needs it, when a particular library contract depends on stable identity, or when the compiler is not enabled for that part of the build. The decision should follow the actual build configuration and profiling result rather than a blanket rule in either direction.
Code splitting
Load a rare, substantial feature when it is needed instead of putting all of its code in the initial bundle:
const Reports =
lazy(
() =>
import(
'./Reports.jsx'
),
);
The lazy component must be rendered under Suspense, with an appropriate loading state. Route-level splitting is often more valuable than splitting every small component because routes tend to define meaningful boundaries and contain more code.
Inspect the production bundle after making the change. Do not assume that a split reduced total work if shared dependencies still dominate the initial chunk or if the deferred chunk is immediately requested.
Also account for the loading experience. A split can reduce initial JavaScript and still introduce a visible delay at the route boundary if the fallback is abrupt or if the request starts too late. Measure both the initial route and the first visit to the lazy feature.
List virtualization
Rendering 50,000 rows creates substantial DOM and layout work even if each row is individually simple. Virtualization renders only the visible window and a small surrounding buffer.
For large data sets, use a mature virtualization library rather than casually inventing scrolling and measurement logic. Virtualization changes more than rendering count, so test:
- keyboard navigation;
- focus management;
- dynamic row heights;
- screen reader behavior; and
- scroll restoration.
If pagination or a simpler rendering strategy meets the product requirement, it may be the better choice. A virtual list introduces accessibility and interaction trade-offs that are part of the feature, not merely an implementation detail.
The visible window is a rendering strategy, not a data-management strategy. The application may still hold all 50,000 records in memory and may still need to filter them. If filtering or parsing is the bottleneck, virtualization alone will not solve it.
useTransition
Some updates must happen immediately, while others can yield to more urgent interaction. Mark the latter with useTransition:
function Search({
tasks,
}) {
const [
input,
setInput,
] =
useState('');
const [
query,
setQuery,
] =
useState('');
const [
isPending,
startTransition,
] =
useTransition();
function change(
event,
) {
const value =
event.target.value;
setInput(value);
startTransition(
() => {
setQuery(value);
},
);
}
const visible =
tasks.filter(
(task) =>
task.title
.toLowerCase()
.includes(
query
.toLowerCase(),
),
);
return (
<section
aria-busy={
isPending
}
>
<input
value={input}
onChange={
change
}
/>
{isPending && (
<p role="status">
Updating results…
</p>
)}
<TaskList
tasks={visible}
/>
</section>
);
}
The controlled input update remains urgent, so the field continues to reflect typing. Updating a large result view can be non-urgent and may yield if another keystroke or interaction arrives.
isPending describes work that React has marked as pending; it is not a request-status flag and it is not proof that a server request is in flight. Keep transport state, error state, and transition state distinct when the result view also loads data.
Transitions are not debounce
A transition changes React's update priority. React can interrupt it, and the component can expose its pending state. It does not:
- wait 300ms;
- cancel a network request;
- cache results; or
- authorize server work.
Use debouncing when the requirement is specifically a time-based delay before processing input. Use AbortSignal or another query-cancellation strategy when the requirement is to cancel a request. Neither concern is handled automatically by transition scheduling.
This distinction is especially important for search. A transition may cause an older render to be abandoned, but an already-started request can still resolve and update application code unless the data layer handles ordering, cancellation, or caching. Scheduling and transport need their own policies.
useDeferredValue
useDeferredValue lets a consumer temporarily lag behind a source value:
const deferredQuery =
useDeferredValue(
query,
);
The source value updates immediately. React may allow the expensive dependent view to continue using the previous value briefly while it works on the new one. This is useful when the input must stay responsive and rendering the dependent view is expensive.
Do not use deferred data for a submitted value, permissions, payment totals, or a destructive decision. Those decisions must use authoritative current data rather than a value that is intentionally allowed to lag.
For a read-only result list, briefly showing the previous query can be a useful continuity trade-off. Label or visually indicate that state when the difference could confuse the user. The UI should make the lag understandable, not silently imply that old results match the newest input.
Interruptible rendering
React can begin rendering non-urgent work and abandon that work before it commits. Render code therefore needs to remain pure.
This is wrong:
function Results() {
analytics.track(
'render results',
);
...
}
An interrupted render could record work that never reached the screen. Put analytics in an event handler or in an appropriate Effect, depending on what the event is meant to represent. Render should calculate its output without causing external side effects.
The same rule applies to subscriptions, imperative DOM work, mutations, and logging that is meant to count committed behavior. Rendering may run more than once, run speculatively, or be discarded. Code that must correspond to committed UI belongs at a lifecycle boundary that has that guarantee.
Activity
React 19.2 Activity can preserve the state of a hidden subtree. This is useful for expensive tabs or workspaces where returning to a tab should restore the user's state instead of rebuilding the entire experience.
There is a real trade-off. Hidden trees can retain memory, and preserved state may be wrong for a sensitive workflow or for a screen that must reset whenever it is left. Choose Activity intentionally rather than treating state preservation as universally desirable.
Before using it, decide what “returning” should mean for that screen. Preserving an editor's draft or a workspace's scroll position may be valuable. Preserving credentials, a completed payment step, or stale permissions may be incorrect. Memory usage should be included in that decision for applications with many workspaces open at once.
Browser performance
React profiling covers only one layer of the browser's work. Also inspect:
- layout shifts;
- forced synchronous layout;
- large images;
- font loading;
- unused JavaScript;
- third-party scripts;
- main-thread long tasks; and
- request waterfalls.
A fast React commit does not prove that the browser can paint the result quickly. The Performance panel is where you correlate scripting with style calculation, layout, paint, and other main-thread activity.
Look for the timing relationship rather than a single isolated duration. A React commit followed by a long layout task points to a different fix than a long JavaScript task before the commit. Network waterfalls and image or font requests may also explain why the interface feels incomplete even when the first paint is quick.
Common mistakes
- memoizing before measuring;
- using
memoaround impure components; - passing unstable props that defeat memoization;
- using
useMemofor trivial values; - treating a transition as network cancellation;
- rendering a giant DOM and then trying to fix it with callbacks;
- using development timing as production evidence; and
- assuming React Compiler excuses impure code.
Exercises
- Profile 10, 1,000, and 10,000 task rows.
- Move search state lower and compare commits.
- Add manual memoization only after identifying a measured hotspot.
- Compare compiler-enabled and manual-memoization code.
- Add
useTransitionto an expensive filter. - Add
useDeferredValueand identify stale UI. - Lazy-load one rare route.
- Document whether virtualization is required.
Exit questions
- Why can a React app be slow even when render is fast?
- What does
memoactually skip? - What does React Compiler change about optimization strategy?
- What does a transition do?
- How is deferred value different from debounce?
- Why must interrupted renders be pure?
Official references
- https://react.dev/reference/react/memo
- https://react.dev/reference/react/useMemo
- https://react.dev/reference/react/useCallback
- https://react.dev/reference/react/useTransition
- https://react.dev/reference/react/useDeferredValue
- https://react.dev/learn/react-compiler
- https://react.dev/reference/react/Activity
Deep dive: performance investigation should follow the critical path
When a user reports that typing is slow, do not begin by guessing which Hook is missing. Trace the complete path:
"typing is slow"
input event
→ state update
→ React render
→ commit
→ browser style/layout/paint
→ any network/effect work
Measure each stage and identify where the time is actually going. The remedy for a slow render is different from the remedy for a slow layout or a request that starts after every keystroke.
React Profiler workflow
Use a production-like build if possible. Then:
- Record one slow interaction.
- Find the expensive commit.
- Inspect which components rendered.
- Ask why each component rendered.
- Measure the component render cost.
- Change one architectural or optimization decision.
- Record the same interaction again.
Do not optimize from component count alone. A large number of inexpensive renders may be harmless, while one expensive calculation or browser layout operation may dominate the interaction.
The profiler answers “what rendered and how long did the commit take?” The browser timeline answers “what else happened on the main thread?” Use both views when the symptom is an interaction delay. A component that appears frequently in a profile is not necessarily the component that caused the delay.
Parent rerender versus child render cost
A parent rerender usually calls child functions unless an optimization or the compiler skips that work. That fact alone is not a performance bug. If child rendering is cheap, allowing it to run can be simpler and fast enough.
The common high-value fix is:
move frequently changing state closer to consumer
That often removes work for an entire part of the tree instead of requiring a memo boundary around every descendant.
Memo boundary trade-offs
memo is not free. It adds:
- prop comparison;
- cognitive complexity; and
- the possibility of stale custom-comparator bugs.
A custom comparator has this shape:
memo(Component, areEqual)
It must compare every prop that can affect the component's output. Ignoring a function prop is particularly dangerous because the function may close over values from an earlier render and produce stale behavior.
Use a custom comparison only when profiling justifies it and you can demonstrate that the comparison is correct for every relevant prop.
When evaluating a comparator, test changed values as well as unchanged values. A comparator that saves work but hides a legitimate update is a correctness bug, not a successful optimization. In many cases, removing a changing prop or narrowing the child API gives a safer boundary.
React Compiler deeper model
The compiler analyzes component and Hook code and can insert memoization-like optimizations. It relies on the Rules of React, including:
- purity;
- the Rules of Hooks; and
- avoiding unsupported mutations and patterns.
Do not reduce the compiler's model to this:
"React makes anything fast automatically"
It reduces the manual memoization burden for compatible code; it does not eliminate the need for good architecture or measurement. The library and application build setup must actually enable the compiler. Verify the configuration instead of assuming that using React 19 means the compiler is running.
This also matters when comparing results. A compiler-enabled benchmark is meaningful only if the build actually contains the compiler transform and uses the same data and interaction as the manual-memoization benchmark. Inspect the build configuration and generated output according to the tooling used by the project.
Compiler directives
React Compiler supports directives such as:
"use memo";
and:
"use no memo";
in relevant scenarios and configurations. These are advanced tools. Do not sprinkle them throughout a codebase without understanding the compiler diagnostics and the reason a particular boundary needs a directive.
ESLint compiler rules
The current React Hooks lint ecosystem includes rules that help preserve compiler-compatible and pure code. Treat lint errors as architecture feedback. Disabling them may hide a violation that prevents optimization or, more importantly, indicates incorrect render behavior.
The linter is not a profiler and a clean lint result is not a performance guarantee. It is a guardrail for patterns the compiler and React can reason about. Pair it with runtime measurement rather than treating either tool as sufficient by itself.
Transition scheduling
Urgent work includes:
typing
click feedback
controlled input
Non-urgent work includes:
expensive result panel update
route content transition
For example:
const [tab, setTab] = useState('summary');
const [isPending, startTransition] = useTransition();
function selectTab(next) {
startTransition(() => {
setTab(next);
});
}
The transition is interruptible. If the user selects another tab before the previous one finishes, React can abandon the old work and prioritize the newer choice.
This does not mean that every intermediate state is guaranteed to be visible or that application code can observe abandoned work as a completed navigation. Design pending feedback around the current committed UI, and make sure the destination handles loading and errors independently.
Transition caveat: controlled input state
Do not put the controlled input's own value update inside a transition:
startTransition(() => {
setInput(event.target.value);
});
Controlled inputs need synchronous urgent updates so that the value the user sees remains coordinated with the input event. Split the two updates instead:
setInput(value);
startTransition(() => {
setQuery(value);
});
Async transitions caveat
When asynchronous work is involved, pay attention to the current React behavior and to where pending state is observed. React Actions can coordinate asynchronous transition workflows.
Do not assume that an arbitrary await preserves every transition-context semantic. Follow the current API patterns for the React version and configuration being used.
Async behavior also raises ordinary data concerns: responses can arrive out of order, failures need to be represented, and cancellation may be necessary. A transition can coordinate the user-interface priority of the workflow, but it does not replace those data-flow decisions.
Deferred values
The basic form is:
const deferredQuery = useDeferredValue(query);
You can detect whether the consumer is behind the source:
const stale = query !== deferredQuery;
For example, the interface can communicate that it is still displaying older results:
<div style={{ opacity: stale ? 0.6 : 1 }}>
<Results query={deferredQuery} />
</div>
That signal is appropriate only when old results are safe to display. Do not present stale sensitive values as though they were current.
Deferred value and network
If Results uses the deferred query as part of its query key, the network request starts when the deferred value changes. The deferred value is not a classic fixed-time debounce; React chooses scheduling based on rendering pressure.
If the server requirement is “wait 300 ms after typing to reduce requests,” implement debouncing explicitly. Add query cancellation and caching where appropriate as separate concerns.
If an older response can arrive after a newer response, the query layer must prevent stale data from replacing current data. Deferred rendering can make the timing of requests less obvious, so inspect request keys and Network-panel activity when debugging this behavior.
Suspense + transition
Navigation can be marked as non-urgent:
startTransition(() => {
setPage(nextPage);
});
If the next page suspends, React can keep the currently revealed content instead of immediately replacing it with a fallback, depending on the boundary. That improves continuity during navigation. Use isPending to provide subtle feedback that the destination is being prepared.
Keep the currently revealed content usable while it remains the best available representation. A pending indicator should explain that navigation is in progress without making the whole page look broken or replacing useful content with a generic spinner.
CPU work
React scheduling cannot rescue a 500 ms synchronous loop once that loop is already executing on the main thread. If a computation is genuinely large, consider:
- improving the algorithm;
- precomputing;
- moving the work to the server;
- a Web Worker;
- incremental processing; or
- virtualization.
Wrapping a CPU-heavy function in useMemo may avoid repeating later calculations, but it does not make the first 500 ms disappear. The initial calculation still has to run.
Scheduling can make other updates more responsive only when React gets an opportunity to yield between units of work. A single synchronous loop, parser, or large serialization step blocks that opportunity. Measure the algorithm and its input size before deciding whether a React-level optimization is relevant.
Web Workers
For large client-side computations such as these:
parse massive file
image processing
complex simulation
a worker can keep the main thread responsive. Communication and serialization cost still matter, especially when large objects cross the worker boundary.
React state can receive the worker's results, but React should not own the worker's internal protocol as render state. Keep the computation and communication lifecycle separate from the state needed to render the result.
Define how the worker starts, receives input, reports progress, handles errors, and terminates. Also measure the cost of copying data across the boundary. Moving work to a worker is useful when main-thread responsiveness is the problem, but it is not free parallelism.
Browser layout performance
React may commit quickly while the browser spends significant time calculating layout. Common sources include:
- thousands of DOM nodes;
- expensive CSS selectors or effects;
- layout reads and writes that force synchronization;
- huge images; and
- complex sticky or fixed positioning.
Use the browser Performance panel as well as the React Profiler. React can show that the component work is inexpensive while the browser timeline shows the real delay after commit.
When investigating layout, look for forced synchronous layout patterns in which code reads geometry after changing styles or classes. Reducing DOM size, batching reads and writes, or changing CSS can solve that class of problem more directly than changing component memoization.
content-visibility and CSS
CSS provides rendering optimizations that React code should reuse where they fit. For example:
.long-section {
content-visibility: auto;
contain-intrinsic-size: auto 500px;
}
This can allow the browser to skip work for content outside the relevant viewport while preserving an estimated intrinsic size. Use it where appropriate and verify the resulting behavior; not every performance problem needs a JavaScript solution.
Virtualization complexity
A virtual list can complicate:
- browser find-in-page expectations;
- screen-reader access;
- focus when an item unmounts;
- variable-height measurement;
- printing; and
- SEO or server-rendered output.
Use pagination or simpler rendering if it satisfies the product need. The fastest list is not a successful feature if users cannot navigate it with a keyboard or assistive technology.
Bundle analysis
Measure the parts of the bundle that affect users:
- initial JavaScript;
- route chunks;
- duplicate dependencies;
- large libraries;
- locale packs;
- icons; and
- editor or chart packages.
Splitting a genuinely large feature is meaningful:
Admin editor 800 KB
Splitting a tiny component usually is not:
2 KB button
The right boundary is the one that keeps substantial, infrequently used work out of the initial path without creating a request waterfall or duplicating shared dependencies.
Bundle analysis should distinguish transfer size from processing time. A compressed file can be small over the network and still cost significant parsing, compilation, or execution time on a slower device. Evaluate the complete initial path, not only the downloaded byte count.
Hydration performance
SSR can improve the time to receive and display initial content, but hydration still requires JavaScript execution. React Server Components can reduce client JavaScript by keeping non-interactive components on the server.
In practice, architecture often affects performance more than memoization. Decide which code needs to run in the browser before trying to optimize every browser-side component.
SSR and RSC solve different parts of the initial experience, and neither removes all client work. Identify which components are interactive, how much JavaScript they require, and what hydration boundary the user actually needs.
Memory
Hidden Activity subtrees, caches, large state objects, image blobs, event listeners, and detached DOM can all retain memory. If a long-running application becomes slower over time, use the browser's Memory tools rather than looking only at render duration.
Performance is not just speed. Memory pressure can trigger collection and reduce responsiveness even when an individual React commit appears reasonable.
Take repeated snapshots or compare retained objects over time when diagnosing a long-running session. A one-time allocation may be expected; a collection of hidden trees, listeners, or blobs that grows after each navigation suggests a retention problem.
Production metrics
Track user-centered metrics such as:
- INP;
- LCP;
- CLS; and
- custom business interactions.
When possible, connect frontend traces with backend request timing. A fast React render cannot make a 2-second API response fast, and a good aggregate metric may hide a slow interaction that matters to a particular workflow.
Include the relevant request and device context in telemetry where privacy and data-retention rules allow it. The goal is to connect a user-visible delay to the layer that can actually be changed.
Failure clinic
useMemo everywhere
The result is more code with little benefit when the calculations are cheap or rarely repeated.
transition around network fetch but no cache/cancellation
Scheduling changes how React handles the update; it does not manage the network transport.
profiler in dev only
Development behavior and tooling can distort timing. Validate important conclusions with a production-like build.
memoized child receives new object/function every time
The prop comparison fails on each render, so the optimization is ineffective.
compiler assumed enabled
React 19 alone does not prove that the build has been configured to run the compiler.
Deep-dive exercises
- Profile controlled search and move its state lower in the tree.
- Compare manual memoization with a compiler-enabled build.
- Add a transition to an expensive result update.
- Add a deferred value and a visual signal for stale results.
- Measure 10k rows, then compare pagination and virtualization.
- Analyze the bundle and lazy-load a genuinely large route.
- Compare React commit time with browser layout time.
- Record one Web Vital or business-interaction metric.
Mastery check
Explain:
- the profiler workflow;
- React Compiler's role;
- urgent updates versus transition updates;
- deferred values versus debounce;
- CPU and main-thread limitations;
- the browser and React performance layers; and
- why architecture often beats manual memoization.
Production case study: diagnosing a slow searchable table
The symptom is concrete:
typing in search box feels delayed at 5,000 rows
The investigation finds:
- React Profiler shows that the entire page rerenders.
- Browser Performance shows expensive table layout.
- Search draft state lives in the page root.
- Every keystroke filters 5,000 records and renders every row.
Fix the problem in layers rather than reaching immediately for three memoization APIs.
Ownership
Move the draft into the Search component if other page regions do not need to observe every keystroke.
Scheduling
Use a committed or deferred search term for the expensive result view:
const deferredQuery = useDeferredValue(query);
Algorithm
Pre-normalize searchable text if normalization is expensive and is being repeated for every record on every keystroke.
DOM
Paginate or virtualize the rows when the rendered DOM is itself a bottleneck.
Server
For very large data sets, move search and pagination to the API so the client does not download, filter, and render everything.
Memo/compiler
Only after the architectural changes, profile again to determine whether component memoization adds value.
This progression is more reliable than starting with this on every function and component:
useCallback
useMemo
memo
Additional depth: React 19.2 Performance Tracks
React 19.2 adds React-specific tracks to Chrome DevTools Performance profiles. They expose information such as:
scheduler priority
blocking work
transition work
component render/effect activity
These tracks bridge the gap between:
React Profiler
and:
browser main-thread timeline
That lets an investigation correlate a sequence such as:
user input
→ blocking React update
→ transition update
→ component work
→ browser paint
Use current Chrome and React DevTools versions that support these tracks.
Example diagnosis
Suppose you type into the search field. The Performance track shows:
blocking update 40 ms
rather than transition work. Inspecting the code reveals that the expensive result update is being made by the same urgent state setter. Split the updates:
setInput(value);
startTransition(() => {
setFilter(value);
});
Record the same interaction again. The lesson is evidence-driven scheduling, not adding transitions everywhere.
Performance budget
For a production feature, define an interaction target before optimizing:
search keystroke remains responsive
route transition feedback < perceived delay threshold
table scroll no sustained long tasks
The exact thresholds depend on the device and product, but a budget turns “make it faster” into a measurable goal.
Use the budget during review and regression testing, not only during a one-time optimization effort. A later feature can reintroduce the same cost through a larger data set, a new dependency, or a layout change. Keeping the interaction target explicit makes that regression easier to detect.
When the measured bottleneck is unclear, isolate one boundary at a time. Temporarily reduce the data set, disable a suspected third-party script in a test environment, or render a lightweight placeholder for the result panel. The comparison should identify which layer changes the interaction time; it should not become a permanent production workaround.
Use a small, repeatable fixture for profiling and then confirm the conclusion with realistic data. Ten rows can hide an algorithmic problem that appears at 10,000 rows, while an unrealistic fixture can exaggerate a cost users never encounter. Both scales are useful when they answer different questions.
The same discipline applies to memory and bundle work. Capture a baseline, make one change, and repeat the measurement under the same conditions. If a proposed optimization improves one metric while worsening another, record that trade-off explicitly instead of calling the result a universal improvement.
An optimization is complete only when the user-visible behavior remains correct. Check the latest search result, keyboard and screen-reader behavior, loading and error states, and navigation restoration after changing scheduling or rendering strategy. Performance work changes timing, so timing-sensitive bugs deserve deliberate testing.
