Sandaruwan Jayasundara home

React Performance Optimization: A Practical 2026 Checklist

Most React performance work is wasted because it starts with a guess. This checklist starts with measurement and then works through the fixes in the order they usually pay off.

Sandaruwan Jayasundara · Updated October 23, 2026 · 7 min read

To optimize a React app in 2026, measure before changing anything, then ship less JavaScript, do less work per render, and render less at once. In practice that means profiling real interactions, letting the React Compiler handle most memoization, code-splitting heavy routes and components, virtualizing long lists, caching server data, and serving properly sized images. Manual useMemo everywhere is rarely the answer.

Key takeaways

  • Measure with field data (Web Vitals) and lab tools (React Profiler, Chrome Performance panel) before optimizing.
  • Bundle size and main-thread work usually matter more than re-render counts.
  • Enable the React Compiler and keep code within the Rules of React; reach for manual memoization only when profiling says so.
  • Move state down, split contexts, and keep server data in a caching library rather than global state.
  • Virtualize long lists, lazy-load below-the-fold code, and fix images — they are often the cheapest wins.

1. Measure first

Field data: Core Web Vitals

Lab tools tell you what can be slow; field data tells you what is slow for real users on real devices. Track LCP, INP and CLS from production with the web-vitals library and send them to your analytics.

import { onLCP, onINP, onCLS } from "web-vitals/attribution";

function send(metric) {
  navigator.sendBeacon("/api/vitals", JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,           // "good" | "needs-improvement" | "poor"
    route: location.pathname,
    target: metric.attribution?.interactionTarget ?? metric.attribution?.element,
  }));
}

onLCP(send); onINP(send); onCLS(send);

The attribution build tells you which element caused a poor INP or LCP, which turns "the app feels slow" into a concrete ticket. Group by route and look at the 75th percentile, which is how Google assesses Core Web Vitals.

Lab data: React Profiler and the Performance panel

  • React DevTools Profiler: record the slow interaction, then look at which components rendered, why ("Why did this render?" setting), and how long each took.
  • Chrome Performance panel: enable CPU throttling (4x–6x) to approximate mid-range phones and look for long tasks over 50 ms.
  • Lighthouse: useful for catching regressions in CI, but don't optimize for the score alone.

2. Fix rendering

Structure state before memoizing

The cheapest render is one that never happens. Before reaching for memoization, check where state lives:

  • Move state down to the component that uses it, so typing in a search box doesn't re-render the whole page.
  • Lift content up: pass expensive subtrees as children, so a parent's state change doesn't re-render them.
  • Split contexts by update frequency, and keep context values stable. One giant "AppContext" re-renders every consumer on every change.
  • Avoid derived state. Compute values during render instead of syncing them with useEffect, which causes an extra render.

The React Compiler changes the memo conversation

The React Compiler automatically memoizes components, props and computed values at build time. For codebases that follow the Rules of React — pure render functions, no mutating props or state — it removes most of the need for React.memo, useMemo and useCallback. Enable it, run the ESLint plugin to find code it has to skip, and fix those violations: they are usually real bugs anyway.

When manual memoization still makes sense

ToolUse it whenCost
useMemoA computation is measurably expensive, or a stable object is needed as an effect dependencyMemory, dependency bugs, noise
useCallbackA function is passed to a memoized child or a library that compares referencesUseless if the child isn't memoized
React.memoA component re-renders often with the same props and is expensiveProp comparison on every render
useTransition / useDeferredValueAn update is heavy but not urgent, such as filtering a large list while typingSlightly stale UI for a moment
function ProductSearch({ products }) {
  const [query, setQuery] = useState("");
  const deferredQuery = useDeferredValue(query);   // input stays responsive (better INP)
  const results = filterProducts(products, deferredQuery);

  return (
    <>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <ResultsList items={results} stale={query !== deferredQuery} />
    </>
  );
}

3. Ship less JavaScript

Code-split by route and by weight

Route-level splitting is the baseline (Next.js does it automatically). The next level is splitting heavy components that aren't needed for first paint: charts, rich-text editors, maps, modals, admin-only panels.

import { lazy, Suspense } from "react";
const RevenueChart = lazy(() => import("./RevenueChart")); // chart library leaves the main bundle

export function Dashboard() {
  return (
    <Suspense fallback={<ChartSkeleton />}>
      <RevenueChart />
    </Suspense>
  );
}

Use Server Components where you can

In the Next.js App Router, components are server components by default. Keep them that way unless they need state, effects or browser APIs; push "use client" down to the smallest interactive leaf. Markdown rendering, date formatting libraries and data transformation then never reach the browser.

Analyze the bundle

  • Run @next/bundle-analyzer, rollup-plugin-visualizer (Vite) or source-map-explorer and look at the largest chunks first.
  • Replace heavy dependencies with lighter ones or native APIs (Intl instead of large date or number libraries where possible).
  • Import specific modules instead of whole libraries, and check that tree-shaking actually works.
  • Add a bundle-size budget to CI so regressions are caught in review, not in production.

4. Render less: lists and virtualization

Rendering thousands of DOM nodes is slow no matter how well memoized each row is. For long lists, render only what's visible with a virtualization library such as TanStack Virtual or react-window.

import { useVirtualizer } from "@tanstack/react-virtual";

function OrdersList({ orders }) {
  const parentRef = useRef(null);
  const v = useVirtualizer({ count: orders.length, getScrollElement: () => parentRef.current, estimateSize: () => 56 });

  return (
    <div ref={parentRef} style={{ height: 600, overflow: "auto" }}>
      <div style={{ height: v.getTotalSize(), position: "relative" }}>
        {v.getVirtualItems().map(row => (
          <OrderRow key={orders[row.index].id} order={orders[row.index]}
            style={{ position: "absolute", top: 0, transform: `translateY(${row.start}px)` }} />
        ))}
      </div>
    </div>
  );
}

Also: use stable, unique keys (never the array index for reorderable lists), and consider pagination or "load more" — often better UX than an endless list. CSS content-visibility: auto is a lighter-weight option for long, mostly static pages.

5. Data fetching and caching

Many "slow React apps" are slow networks in disguise: request waterfalls, refetching the same data on every mount, and spinners stacked on spinners.

  • Use a server-state library (TanStack Query, SWR, or framework data loaders) for caching, deduplication and background revalidation instead of hand-rolled useEffect fetching.
  • Kill waterfalls: fetch in parallel, fetch on the server or in route loaders, and prefetch on hover or viewport for likely next pages.
  • Return less data: paginate and select only needed fields — API design matters, as covered in how to design REST APIs properly.
  • Stream slow sections with Suspense so the rest of the page renders immediately.

6. Images, fonts and layout shift

  • Serve modern formats (WebP/AVIF) at the size actually displayed, with srcset/sizes or next/image.
  • Mark the LCP image as high priority (fetchpriority="high" or priority in Next.js) and never lazy-load it.
  • Lazy-load below-the-fold images, and always set width and height (or aspect-ratio) to prevent CLS.
  • Self-host fonts, subset them, and use font-display: swap or next/font.

The checklist

  1. Field Web Vitals collected per route, with attribution.
  2. Slowest interactions profiled with CPU throttling.
  3. React Compiler enabled; lint violations fixed.
  4. State colocated; contexts split; no derived state in effects.
  5. Heavy components lazy-loaded; client components kept small.
  6. Bundle analyzed; size budget enforced in CI.
  7. Long lists virtualized or paginated.
  8. Server data cached; waterfalls removed.
  9. LCP image prioritized; images sized; fonts optimized.
  10. Re-measure, and keep the numbers on a dashboard.

Performance is also an architecture concern; many of these choices are easier when made early, as discussed in how to build scalable web applications.

FAQ

Do I still need useMemo and useCallback with the React Compiler?

Much less often. The React Compiler memoizes components and values automatically when your code follows the Rules of React. Manual useMemo and useCallback remain useful as an escape hatch, for code the compiler skips, and when a stable reference is required by an effect dependency or a third-party library.

What is the most common cause of slow React apps?

In practice it is usually too much JavaScript shipped on initial load and too much work on the main thread, followed by unnecessary re-renders caused by state placed too high in the tree or context values that change on every render. Measuring with the Profiler and a bundle analyzer tells you which one applies.

Which Core Web Vitals matter most for React apps?

Largest Contentful Paint (LCP), Interaction to Next Paint (INP) and Cumulative Layout Shift (CLS). React apps most often struggle with INP, because heavy renders and long tasks delay the response to clicks and typing, and with LCP when the main content waits on client-side JavaScript and data fetching.

When should I virtualize a list?

As a rule of thumb, consider virtualization when a list renders several hundred or more complex rows, or when the Profiler shows the list dominating render time. For shorter lists, pagination or simply making rows cheaper is usually enough, and avoids the accessibility and scroll-restoration trade-offs of virtualization.

Is Next.js faster than a plain React single-page app?

Not automatically, but it makes fast defaults easier: server rendering and React Server Components reduce client JavaScript, the image component handles responsive images, and routes are code-split by default. A poorly structured Next.js app can still ship large client bundles, so measuring still matters.

Need a performance review?

If your React or Next.js app is slow and you'd like help finding out why, take a look at my React and Next.js development services or send me a message with your Web Vitals and the pages that hurt most.

Related articles

Need help with your project?

I take on a limited number of remote engagements. Explore my services or get in touch.

Contact Sandaruwan