Overview
React performance work has a fixed order: measure with a profiler, find the real hot path, then apply the cheapest fix that hits it. “Optimizations” written without a profile often add cost, since a useMemo around a cheap expression costs more than the expression. The React Compiler covers most memoization but is opt-in. For field metrics see core-web-vitals.
Profile before you change anything
Record a real interaction (typing, scrolling, a tab switch) in the React DevTools Profiler and read the flamegraph for components that render too often, not only ones that render slowly.
- Profile a production build with React’s profiling build; development mode adds checks that distort timings.
- Throttle CPU in the browser’s performance panel so a fast machine does not hide user-visible problems.
- Use the React 19.2 Performance tracks (Scheduler and Components) in Chrome DevTools to see render priorities and Effect timing next to the browser’s own timeline.
Treat memoization as a last resort
With the compiler on, useMemo and useCallback are rare. Without it, add them only when the profile shows a cost and the value feeds a memo() child with an expensive render, is a genuinely heavy computation, or must stay stable as another hook’s dependency. The full rule is in react-compiler and react-hooks.
Virtualize long lists
Rendering thousands of rows as DOM nodes is a common bottleneck. TanStack Virtual (@tanstack/react-virtual) mounts only the rows in view.
"use client";
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
overscan: 8,
});
// Render virtualizer.getVirtualItems(), positioning each row with translateY(item.start).Keep estimateSize close to the real row height to avoid layout thrash, use measureElement for variable heights, and raise overscan if fast scrolling shows blank rows.
Split code at route boundaries
Split at the router. Next.js splits every route segment automatically; framework-free apps use React.lazy with a Suspense boundary.
const Settings = lazy(() => import("./settings"));
<Suspense fallback={<RouteSkeleton />}><Settings /></Suspense>Scattered lazy boundaries trigger network requests mid-interaction. Reserve component-level splits for heavy, rarely used widgets (editors, charts) and prefetch them on intent, such as hover on the nav link.
Remove data waterfalls
A waterfall is a chain of awaits where later calls did not need the earlier result. Start independent fetches together and await once.
// Wrong: network time = A + B + C.
const user = await getUser(id);
const posts = await getPosts(id);
// Right: network time = max(A, B).
const [user, posts] = await Promise.all([getUser(id), getPosts(id)]);In client trees, render sibling Suspense boundaries so each region fetches in parallel; see react-suspense.
Read high-frequency state through selectors
A context that publishes a whole state object re-renders every consumer on every change. A Zustand or Jotai selector re-renders only when the selected slice changes: useCart((s) => s.items.length). Context stays fine for low-frequency values. See react-state-management.
Use memo, useDeferredValue, and useTransition for what remains
memo(Component)skips a render when props are referentially equal; it does nothing without stable props.useDeferredValue(input)keeps an input responsive while an expensive consumer catches up.useTransition()marks an update non-urgent so the UI stays interactive.
If one component needs all three to feel fast, simplify the component.