Skip to content

React Performance Optimization

Aman Kumar Singh8 min read
Part 9 of 50From the React & Next.js Complete Guide series
React Performance Optimization — article by Aman Kumar Singh

In Using React Context the Right Way I covered how context can quietly turn into a performance problem if you put fast-changing state next to slow-changing state in the same provider. That article was really a specific case of a broader habit: most React performance problems come from a component tree doing more work than the UI actually needs. React itself is rarely the slow part.

This article is part of the React & Next.js Complete Guide series, and it is the one where we step back and treat performance as a discipline rather than a reaction. The goal is to build a way of thinking about where time actually goes in a React app, so you fix the right thing instead of the first thing that looks suspicious. A list of tricks to sprinkle on a component won't get you there.

Deep, targeted re-render debugging gets its own article next in this series. Here I am covering the wider surface: measurement, code splitting, list rendering, memoization strategy, and the tradeoffs Next.js introduces once server rendering enters the picture.

Measure before you optimize

The single most common mistake in performance work is skipping straight to the fix. Someone notices a page feels slow, wraps three components in React.memo, adds a useMemo here and there, and moves on without confirming what was actually expensive. Sometimes that intuition is right. Often it just adds indirection to code that was never the bottleneck, while the real cost, an oversized bundle or a layout-thrashing loop, goes untouched.

React DevTools ships a Profiler tab for exactly this reason. Recording an interaction shows you which components rendered, how long each one took, and why it rendered at all, including whether it was a state change, a parent re-render, or a context update. That last piece, the "why did this render" reason, is the part people skip, and it's the part that actually points you at the fix.

import { Profiler, type ProfilerOnRenderCallback } from "react";

const onRenderCallback: ProfilerOnRenderCallback = (
  id,
  phase,
  actualDuration,
  baseDuration
) => {
  // actualDuration: time spent rendering this commit
  // baseDuration: estimated time to render the whole subtree without memoization
  if (actualDuration > 16) {
    console.warn(`[perf] ${id} (${phase}) took ${actualDuration.toFixed(1)}ms`);
  }
};

export function InstrumentedDashboard({ children }: { children: React.ReactNode }) {
  return (
    <Profiler id="dashboard" onRender={onRenderCallback}>
      {children}
    </Profiler>
  );
}

Outside of component render time, browser-level performance work should lean on Core Web Vitals: Largest Contentful Paint for perceived load speed, Interaction to Next Paint for responsiveness, and Cumulative Layout Shift for visual stability. Lighthouse and the Chrome Performance panel give you these numbers directly, and they catch a category of problem the React Profiler never will: a render that is fast in React terms but still blocked behind a slow network request or a huge synchronous script parse.

Code splitting and lazy loading

Every kilobyte of JavaScript shipped to the browser has to be downloaded, parsed, and executed before your app is interactive, regardless of whether the user ever touches the feature that JavaScript belongs to. A settings page, an admin panel, a rich text editor: these are exactly the kind of features that do not need to be in the initial bundle for every visitor.

React.lazy combined with Suspense defers loading a component's code until it is actually rendered:

import { lazy, Suspense } from "react";

const AnalyticsDashboard = lazy(() => import("./AnalyticsDashboard"));

function SettingsPage() {
  return (
    <Suspense fallback={<DashboardSkeleton />}>
      <AnalyticsDashboard />
    </Suspense>
  );
}

In a Next.js app, next/dynamic does the same job with a couple of extra knobs that matter in practice: an ssr: false option for components that depend on browser-only APIs, and automatic route-level code splitting that happens without any manual work on your part.

import dynamic from "next/dynamic";

const RichTextEditor = dynamic(() => import("./RichTextEditor"), {
  ssr: false,
  loading: () => <EditorSkeleton />,
});

The tradeoff is a small delay the first time a lazily loaded chunk is requested, plus a loading state you now need to design for. That is a fair trade for most secondary features, but it is the wrong call for anything above the fold on your most-visited pages. Splitting your primary landing experience into three sequential chunks just moves the slowness from "download" to "waterfall of downloads," which is often worse.

Run a bundle analyzer before deciding what to split. @next/bundle-analyzer or webpack-bundle-analyzer will show you which dependencies are actually large, and it is common to find that one moment-style date library or an entire icon set pulled in for a single icon is doing more damage than any component logic.

Virtualizing long lists

Rendering a thousand rows into the DOM, even if each row is a simple component, means a thousand real DOM nodes that the browser has to lay out, paint, and keep around for every scroll and resize event. React's virtual DOM diffing does not save you here: the cost is in the browser's actual DOM, not in React's reconciliation.

List virtualization solves this by rendering only the rows currently visible in the viewport, plus a small buffer, and swapping DOM nodes in and out as the user scrolls. @tanstack/react-virtual is the option I reach for now; it is unopinionated about markup and works well with variable row heights.

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

function TransactionList({ transactions }: { transactions: Transaction[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: transactions.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 56, // px, approximate row height
    overscan: 8,
  });

  return (
    <div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
      <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
        {virtualizer.getVirtualItems().map((item) => (
          <div
            key={item.key}
            style={{
              position: "absolute",
              top: 0,
              transform: `translateY(${item.start}px)`,
              height: item.size,
              width: "100%",
            }}
          >
            <TransactionRow transaction={transactions[item.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

This is worth adding once a list regularly exceeds a few hundred rows, not before. Virtualization adds real complexity: absolute positioning breaks native browser find-in-page, it complicates accessibility for screen readers navigating by row, and any row with variable, unpredictable height needs careful measurement logic. For a list of twenty or fifty items, plain rendering plus pagination is simpler and does the job.

Memoization as a scalpel, not a default

React.memo, useMemo, and useCallback all exist to skip work that would otherwise repeat unnecessarily. They are worth using deliberately in two situations: an expensive computation that would otherwise rerun on every render, and a prop passed to a component wrapped in React.memo that needs a stable reference to actually prevent that component from re-rendering.

const sortedRows = useMemo(
  () => rows.slice().sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()),
  [rows]
);

Wrapping every component in React.memo by default is a common overcorrection. The comparison itself costs something on every render, and if the props passed down are frequently new object or array literals, the memoization never actually skips a render, it just adds a wasted equality check before rendering anyway. The full treatment of when re-renders actually matter, and how to structure component trees so they happen less, is the subject of the next article in this series. For now, the rule that holds up: profile first, memoize the specific thing the profiler flagged, and leave the rest alone.

Next.js specific considerations

Server Components change the performance conversation because they let you keep data-fetching and non-interactive rendering entirely off the client bundle. A component that only reads from the database and renders markup never needs to ship its JavaScript to the browser at all. Only components marked "use client" do. Auditing which components actually need interactivity, versus which ones just render server data, is often the single highest-impact performance pass available in a Next.js App Router codebase, and it has no equivalent in a client-only React app.

Streaming with Suspense boundaries at the route level lets the server send the shell of a page immediately and stream in slower data-dependent sections as they resolve, instead of blocking the entire response on the slowest query. This directly improves perceived load time without touching a single line of rendering logic, since the bottleneck it addresses is data-fetching latency, not React render cost.

import { Suspense } from "react";

export default function OrdersPage() {
  return (
    <div>
      <OrdersHeader />
      <Suspense fallback={<OrdersTableSkeleton />}>
        <OrdersTable />
      </Suspense>
    </div>
  );
}

Image handling is worth a specific mention because it is such a common source of avoidable Largest Contentful Paint regressions. next/image handles responsive sizing, lazy loading below the fold, and modern format negotiation automatically. Skipping it in favor of a plain <img> tag is one of the most common regressions I see reviewing Next.js pages, usually introduced by someone porting a component from a non-Next.js project without adjusting it.

Production pitfalls worth naming directly

A few patterns show up repeatedly once an app is in production long enough to accumulate rough edges. Watch for third-party scripts loaded synchronously in <head> that block the main thread before your app mounts; defer or lazy-load anything not required for first paint. Large client-side state objects held in context are another one: they force every consumer to re-render on any change, the exact failure mode from the context article referenced above. The subtler problem is performance work that never gets re-measured after shipping: a fix validated during one profiling session can regress quietly as new features get added around it, and without a Web Vitals dashboard tracking real user data, nobody notices until a user complains.

Key takeaways

  • Profile before optimizing. React DevTools Profiler tells you why a component rendered; Core Web Vitals tell you whether the user actually experienced anything slow.
  • Code-split anything that is not needed for the initial view, using `React.lazy`/`Suspense` or `next/dynamic`, but avoid splitting your above-the-fold critical path into a waterfall of sequential chunks.
  • Virtualize lists once they regularly exceed a few hundred rows; below that threshold the added complexity is not worth it.
  • Use `useMemo`, `useCallback`, and `React.memo` as targeted fixes for a specific measured cost, not as default hygiene applied to every component.
  • In Next.js, prefer Server Components for anything non-interactive, stream slow data-dependent sections with `Suspense`, and use `next/image` instead of a plain `<img>` tag.
  • Performance work needs to be re-measured over time. A fix validated once during a profiling session can regress quietly as the codebase grows around it.

Frequently asked questions

Does React 18's automatic batching help with performance?

Yes, indirectly. Automatic batching groups multiple state updates inside event handlers, promises, and timeouts into a single re-render instead of one render per `setState` call, which reduces redundant render work without any code changes required on your part.

Is React.memo worth adding to every component "just in case"?

No. The equality check itself has a cost, and if the props a component receives are new object or array literals on every parent render, `React.memo` never actually skips anything, it just adds overhead before rendering regardless. Add it where profiling shows a component re-rendering with identical props.

How do I know if my bundle size is actually a problem?

Run a bundle analyzer and look at what is shipped for your most-visited routes specifically, not your total app size. A large admin panel bundle rarely matters if it is properly code-split away from the marketing pages and dashboard most users hit first.

Should I virtualize a table that only ever shows 50 rows?

Generally no. Virtualization adds real complexity around accessibility, find-in-page, and variable row height handling. Fifty rows render fine without it; reserve virtualization for lists that regularly run into the hundreds or thousands.

What is the difference between LCP and TTI for React apps?

Largest Contentful Paint measures when the biggest visible element finishes rendering, which is mostly a network and initial-render concern. Time to Interactive (superseded in practice by Interaction to Next Paint) measures how quickly the page responds to user input after that, which is where heavy client-side JavaScript and long tasks show up.

Can server-side rendering alone fix a slow React app?

No. SSR improves the initial paint and gives search engines and users something meaningful faster, but once the client-side JavaScript hydrates, all the same render-cost and re-render problems from a pure client app still apply. SSR and client-side performance work address different parts of the timeline.

Related articles

Core Web Vitals for React Apps — Aman Kumar Singh
Image and Asset Optimization — Aman Kumar Singh
Code Splitting and Lazy Loading — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.