React performance: what to fix first

Checklist 9 min read Updated August 2026

Fast React apps come from a handful of repeatable habits, not heroics. Run this checklist before you ship a feature — work top to bottom, measure as you go, and only optimize what the numbers say is slow.

Cut wasted re-renders

The single biggest lever. Stop components from rendering when their data hasn't changed:

  • Keep component state local. Lift state only as high as it needs to go — high state re-renders whole trees.
  • Split large contexts. One context per concern so a change doesn't re-render every consumer.
  • Stabilize props. Avoid new object/array/function literals in JSX passed to memoized children; wrap with useMemo/useCallback.
  • Memoize hot children with React.memo where they render often with the same props.
  • Use stable keys in lists — never the array index for dynamic lists.
  • Move expensive work out of render — compute in useMemo or a worker, not inline every render.

The most common offender is a fresh function passed to a memoized child on every render:

// new function each render — defeats React.memo on <List/>
<List onSelect={(id) => open(id)} />

// stable reference — <List/> only re-renders when data changes
const handleSelect = useCallback((id) => open(id), [])
<List onSelect={handleSelect} />

React.memo only pays off when its props are referentially stable, so memoizing the child and stabilizing its callbacks go together — one without the other does nothing. For expensive derived values, memoize the computation itself:

// re-sorts on every render, even when `rows` is unchanged
const sorted = rows.slice().sort(compare)

// recomputes only when `rows` changes
const sorted = useMemo(() => rows.slice().sort(compare), [rows])

Rule of thumb: reach for useMemo/useCallback when the value feeds a memoized child, is an effect dependency, or is a computation over ~1 ms — not by default. Wrapping every trivial value adds noise and its own overhead.

Trim the bundle

  • Code-split routes with React.lazy + Suspense so users download only what they see.
  • Lazy-load heavy components (editors, charts, modals) on demand.
  • Audit dependencies. Replace or drop large libraries; prefer tree-shakeable imports.
  • Analyze the bundle to find the biggest chunks before guessing.
// the Settings route ships only when the user navigates to it
const Settings = React.lazy(() => import("./Settings"))

<Suspense fallback={<Spinner />}>
  <Settings />
</Suspense>

Concrete targets: keep the initial JS payload under ~150–200 KB gzipped, and route anything a user can't see on first paint (charts, rich editors, modals, admin screens) into its own chunk. A single heavy dependency — a moment.js, a full charting library, an icon set imported as one barrel — is often 30–50 KB on its own. Find the offenders with source-map-explorer or vite-bundle-visualizer before optimizing; guessing usually trims the wrong thing.

Practice this
Apply it on real React challenges
Refactor components and fix performance in a full browser IDE — graded automatically.
Practice React

Fix data & network

  • Cache server data with a data layer (e.g. a query cache) instead of refetching on every mount.
  • Avoid request waterfalls — fetch in parallel, not one-after-another.
  • Paginate or virtualize long lists instead of rendering thousands of rows.
  • Debounce search and input-driven requests.

A waterfall is three requests that each wait for the last when only some actually depend on each other:

// waterfall — three round-trips in series
const user = await getUser(id)
const org  = await getOrg(user.orgId)
const plan = await getPlan(org.planId)

// parallel where there's no dependency — one round-trip of latency
const [user, settings] = await Promise.all([getUser(id), getSettings(id)])

For long lists, virtualization is the bigger win than pagination: a table of 5,000 rows is 5,000 DOM nodes the browser must lay out and paint, but with react-window or @tanstack/virtual you render only the ~20 rows in the viewport and recycle them as the user scrolls.

Optimize assets

  • Serve right-sized, modern images (WebP/AVIF) and lazy-load below the fold.
  • Reserve space for images/embeds to avoid layout shift (CLS).
  • Preload critical fonts and subset them; avoid blocking render.

Measure it

  • React Profiler — find which components render often and how long they take; a commit over ~16 ms drops a frame at 60 fps.
  • Lighthouse — a repeatable lab score for load and interactivity; run it in an incognito window to avoid extension noise.
  • Core Web Vitals — track real users, not just your laptop. Aim for LCP < 2.5 s, INP < 200 ms, and CLS < 0.1 at the 75th percentile; anything above is what to fix first.

Quick-win checklist

  • Stable keys on every dynamic list
  • Route-level code splitting in place
  • No new inline objects/functions to memoized children
  • Server data cached, requests parallelized
  • Images sized, modern format, lazy-loaded
  • Profiled before and after each change

FAQ

What's the biggest cause of slow React apps?

Unnecessary re-renders. Components re-rendering when their data hasn't changed — usually from new object or function references passed as props, or unsplit context — is the most common and most fixable cause.

Should I wrap everything in useMemo and useCallback?

No. Memoization has a cost and adds noise. Reach for it when a value or callback is passed to a memoized child, is an expensive computation, or is a dependency of an effect — not by default.

How do I know if a change actually helped?

Measure before and after. Use the React Profiler for render cost and Lighthouse or Web Vitals for real user metrics. Optimizing without measuring usually just moves the problem.

Put it into practice

Solve real challenges in a full browser IDE — graded automatically.

Browse frameworks