Overview

React performance work has a strict order. Measure with the DevTools Profiler. Identify the actual hot path. Apply the cheapest fix that hits it. Most “optimizations” written without a profile add cost; useMemo on a cheap expression is slower than the expression. The React 19 compiler also memoizes most of what teams used to write by hand. This page covers the order of operations and the fixes that earn their bookkeeping. For the broader Core Web Vitals story, see core-web-vitals.

Profile before you change anything

Open React DevTools, switch to the Profiler tab, record an interaction, and read the flamegraph. The hot component is the one wasting the most time, not the one that looks suspicious. Pair it with the browser performance panel for paint, layout, and JavaScript timing.

  • Profile in production mode (NODE_ENV=production). Development mode is two-to-five times slower and lies about the bottleneck.
  • Throttle CPU to 4x or 6x in DevTools so a fast machine does not hide problems users have.
  • Record a real interaction (typing, scrolling, tab switch), not a page load.
  • Look for components that render too often, not just components that render slowly.

Without a profile, performance work is guesswork that usually makes things worse.

Treat memoization as a last resort

The React 19 compiler memoizes most stable expressions. useMemo and useCallback are now scalpels, not defaults. Reach for them only when the profiler shows the cost and one of these is true:

  • The value is a reference passed to a memo() child whose render is expensive.
  • The computation is genuinely heavy: large array transforms, regex compilation, parsing.
  • The value is a dependency of another hook whose identity must stay stable.

Wrapping a cheap expression in useMemo adds a dependency check on every render. It is a tax. See react-hooks for the full rule.

Virtualize lists past a few hundred rows

A list of 5,000 DOM nodes is the most common performance footgun in React. Use TanStack Virtual to render only the rows currently in view; scroll position drives which rows mount and unmount.

"use client";
import { useVirtualizer } from "@tanstack/react-virtual";
 
function TallList({ rows }: { rows: Row[] }) {
  const parentRef = useRef<HTMLDivElement>(null);
  const virtualizer = useVirtualizer({
    count: rows.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 48,
    overscan: 8,
  });
  return (
    <div ref={parentRef} className="h-[600px] overflow-auto">
      <div style={{ height: virtualizer.getTotalSize() }} className="relative">
        {virtualizer.getVirtualItems().map((v) => (
          <div key={v.key} style={{ transform: `translateY(${v.start}px)` }} className="absolute inset-x-0 h-12">
            {rows[v.index].label}
          </div>
        ))}
      </div>
    </div>
  );
}

Set estimateSize close to the real row height to avoid layout thrash. Tune overscan for scroll smoothness; eight rows above and below is a reasonable default.

Split code at route boundaries

The router is the right place to split bundles. Next.js 15 splits at every route segment automatically; framework-free apps use React.lazy plus a Suspense boundary.

const Settings = lazy(() => import("./settings"));
 
<Suspense fallback={<RouteSkeleton />}>
  <Settings />
</Suspense>

Avoid component-level lazy boundaries inside a page; they trigger a network request mid-interaction and feel slower than the bundle they save. Split at the route, prefetch on hover for nav links, and the user pays the cost once per route. See react-suspense.

Avoid waterfalls with parallel data fetching

A waterfall is a chain of awaits where each depends on the last but did not have to. The page loads one network hop at a time, and the user waits for the sum.

// Wrong: sequential, network = A + B + C.
const user = await getUser(id);
const posts = await getPosts(user.id);
const tags = await getTags(user.id);
 
// Right: parallel, network = max(A, B, C).
const [user, posts, tags] = await Promise.all([
  getUser(id), getPosts(id), getTags(id),
]);

In server components, kick off independent fetches with Promise.all and await once. In client components, render sibling Suspense boundaries so each fetches in parallel. The Suspense model is built for this: parallel reads with declarative loading states.

Read stores with selector hooks, not whole-state context

A context that publishes a full state object re-renders every consumer on every change. A Zustand or Jotai selector hook re-renders only when the selected slice changes.

// Wrong: context with a big object, every consumer re-renders.
const value = useContext(AppContext);
 
// Right: selector reads only what the component uses.
const itemCount = useCart((s) => s.items.length);

For low-frequency context values (theme, locale, auth user), context is fine. For high-frequency state, use a store with selectors. See react-state-management for the wider decision tree.

Use memo, useDeferredValue, and useTransition for the last 10%

After profiling, virtualization, and code splitting, three React primitives clean up the remaining hot spots.

  • React.memo(Component): skip re-render when props are referentially equal. Pair with a stable reference; otherwise it is a no-op.
  • useDeferredValue(input): let the input render at 60fps while the expensive consumer catches up.
  • useTransition(): mark a state update as non-urgent so the UI stays responsive.

These are tuning tools, not architecture. If a component needs three of them to feel fast, it needs to be simpler.