0

Back to Catalogue

Table of Contents

React performance optimization: how to find what's slow and fix it in the right order

We’ve seen it all and can attest that this common problem is easy to fix with specific optimization techniques that will rescue you from subpar performance.

3 min read
post image

Your React app used to feel fine. Now support tickets mention lag, the sales demo stutters halfway down a long table, and somebody on the team has already opened a pull request that wraps half the component tree in React.memo.

That pull request is the part worth pausing on. Most React performance optimization goes wrong the same way: the team picks a technique before it knows which failure mode it is actually looking at, spends a sprint on it, and afterward cannot say whether anything got better. Excessive re-renders, a bloated bundle, an unvirtualized list, and a slow network on a mid-range Android phone all feel like "the app is slow", and each one needs a different fix.

One warning before you go looking for answers elsewhere. React's own "Optimizing Performance" guide is still easy to land on, but check the domain first. It lives on legacy.reactjs.org, which react.dev/versions confirms is the archived React 18 documentation. The page teaches shouldComponentUpdate and PureComponent. It names no hook, no React Compiler, no Suspense, no Server Components, and no Core Web Vitals. Good advice, for 2018.

This guide is ordered the way a team works when it inherits a slow codebase: symptom, then measurement, then the fix, then the number that proves the fix landed. It also answers the question that changed everything about React code optimization in the last year: how much of the classic manual work the compiler now does for you, and what is still yours.

Start with the symptom

Before you read another list of React performance optimization techniques, name what your users are complaining about. Four symptoms cover almost everything.

What people reportUsual causeWhere to look first
"It freezes when I type / click / filter"Too many components re-rendering per keystrokeReact DevTools Profiler, INP in field data
"It takes forever to open"Bundle size and the request waterfall behind itBuild output, Network panel, LCP
"Scrolling this list is painful"Thousands of DOM nodes, no windowingProfiler flame chart, element count
"It's fine here, users say it's slow"Slower devices, slower networks, cold cachesReal user monitoring, not your laptop

The rest of this article follows that order, because that is the order in which the diagnosis narrows.

The interface stalls when users type or click

This is a rendering problem, and it is the most common one in React. A state update near the top of the tree re-renders far more of the tree than it needs to, and the browser cannot paint the next frame until that work finishes. Typing in a filter box that drives a big list is the classic case.

The first load takes too long

This is a loading problem. It comes down to how much JavaScript you ship, when you ship it, and what has to finish before anything meaningful appears. Manual memoization will not move it at all. Code splitting, server rendering, and asset weight will.

A long list stutters when scrolling

React long list performance almost always comes down to one thing: you are mounting every row. Two thousand rows means two thousand component instances and their DOM nodes, whether anyone can see them or not.

It is fast on your machine and slow for your users

Your machine is a bad sample. It has a fast CPU, a warm cache, and, most likely, an office connection. If local profiling looks clean while complaints keep arriving, you need field data before you change any code.

Set targets you can actually check

"Faster" is not a goal. Before you optimize a React application, decide which numbers you are moving and what counts as good enough, so you can stop when you get there.

INP, LCP, and CLS: the numbers that define "fast enough"

Core Web Vitals give you thresholds you did not have to invent, and they are the same ones Google measures.

  • INP (Interaction to Next Paint) measures responsiveness. Per web.dev, an INP at or below 200 ms is good, above 200 ms and up to 500 ms needs improvement, and above 500 ms is poor.
  • LCP (Largest Contentful Paint) measures loading. web.dev recommends it to be within 2.5 seconds of when the page starts loading.
  • CLS (Cumulative Layout Shift) measures visual stability, with good at 0.1 or less.

All three are assessed at the 75th percentile of page loads, segmented across mobile and desktop. That percentile matters more than the metric names. You are not optimizing for the median session; you are optimizing so that three sessions out of four clear the bar.

Why INP deserves attention in React apps

INP became a Core Web Vital and replaced First Input Delay on 12 March 2024. Unlike FID, it captures the longest interaction from input through event-handler work to the next frame the browser can present.

React rendering can contribute to that latency, especially when one interaction triggers a large render cascade. It is not the only cause: long tasks, expensive handlers, layout, and presentation work can dominate too. If users report lag during clicks, typing, or filtering, start with INP and profile the specific interaction.

Lab data and field data answer different questions

Lab tools (Lighthouse, your local Profiler) are reproducible and let you compare two builds. Field data tells you what actually happened to real people on real devices. They routinely disagree, and that disagreement is expected, not a bug in your setup. Use lab data to decide whether a change helped, and field data to decide what to work on.

Measure before you change anything

Take a baseline. Without one, you cannot tell an improvement from a placebo, and you cannot defend the sprint you spent. The good news about React performance profiling is that the tools you need are free and, in most cases, already installed.

React DevTools Profiler and React Performance Tracks

React Developer Tools is a browser extension for Chrome, Firefox, and Edge; installing it adds the Components and Profiler panels. For Safari and other browsers, React documents a different route: install the react-devtools npm package and open the devtools from your terminal.

In the Profiler, record a session while you reproduce the symptom, then read the commits. You are looking for components that render often, render slowly, or render when nothing they display has changed. The "why did this render?" data is usually the fastest way from a vague complaint to a specific line of code.

React 19.2 also ships React Performance Tracks: custom entries on the Performance panel timeline that put React's own scheduling and component work next to network requests, JavaScript execution, and event loop activity on one timeline. They are available in development and profiling builds. When you need to see React's work in the context of everything else the browser is doing, this is the view.

The Profiler API and the Chrome DevTools Performance panel

The <Profiler> component does the same measurement programmatically. Wrap a subtree, receive timing in an onRender callback, and log it wherever you collect metrics. It is useful for watching one suspect area across a whole QA run instead of a single recording.

For everything outside React (long tasks, layout thrashing, main-thread blocking from a third-party script), record in the Chrome DevTools Performance panel with CPU throttling on. Plenty of "React is slow" reports turn out to be an analytics bundle holding the main thread.

What the Profiler does not tell you

This part gets skipped, and it causes bad decisions.

The <Profiler> reference says that is lets you measure rendering performance of a React tree, and that is the whole of its scope. It measures the cost of rendering your React tree, not the overall speed of your application. A page can have an immaculate Profiler trace and still miss its LCP target by a wide margin, because the time went into the network, an oversized bundle, or work outside React entirely.

The same page carries a second caveat worth repeating to anyone who plans to profile in production: profiling adds overhead, so it is disabled in the production build by default. Opting into production profiling requires a special production build with profiling enabled.

So the Profiler answers "which component is expensive to render". It does not answer "is this fast for our users". Different question, different instrument.

Real user monitoring, and why tools on their own optimize nothing

That second question needs field data: Core Web Vitals collected from real sessions, segmented by device class, connection, and route. The Chrome UX Report gives you a free baseline for public pages that get enough traffic to qualify, and any RUM product will give you per-route detail behind a login.

One caveat applies to every entry on any list of React performance optimization tools, and it is worth saying plainly before you buy anything. React performance monitoring tools help you identify bottlenecks and areas for improvement, but they do not optimize your React app themselves. They are diagnostic instruments. Buying a second dashboard while the first one already tells you which route misses the INP threshold is not measurement; it is procrastination. Pick one source of field data, wire it to your routes, and go fix the route.

What React Compiler already does for you

This is the section that makes much of the older React performance advice obsolete, including large parts of what this article used to say.

What shipped in React Compiler 1.0

React Compiler 1.0, the first stable release, shipped on 7 October 2025. It is a build-time tool that optimizes your app through automatic memoization, and it optimizes components and hooks without requiring rewrites. It is compatible with React 17 and up; on older versions, you set a minimum target in the compiler config and add react-compiler-runtime.

It installs across Babel, Vite, Metro, and Rsbuild, and Next.js users can enable the swc-invoked compiler from v15.3.1 up. The Rules of React linting that used to live in eslint-plugin-react-compiler now live in eslint-plugin-react-hooks, and React recommends the recommended preset to switch the compiler rules on. React's own report from the Meta Quest Store is measured, not promotional: initial loads and cross-page navigations improved by up to 12%, some interactions more than 2.5× faster, memory neutral, with an explicit "your mileage may vary". Treat that as a reason to try it on your app, not as a number to expect.

If you want the wider context of what changed between React 18 and 19, we covered it separately in what React is in 2025 and why React 19 changed front-end again.

What the compiler handles automatically

The compiler documentation is specific about the target. Automatic memoization is aimed at update performance, and that covers two cases.

  1. Cascading re-renders. Re-rendering a parent drags its subtree along even when only the parent changed. The compiler works out which parts of the output can be reused and stops the cascade.
  2. Expensive calculations inside components and hooks. The kind of work you would previously have wrapped in useMemo by hand.

It also fixes a bug that manual memoization keeps producing. Wrap a handler in useCallback, then pass onClick={() => handleClick(item)} inside a map, and a new function is created on every render, which breaks the memoization you just wrote. React's docs use exactly this example. The compiler optimizes it correctly either way.

What still needs a decision from you

The compiler is not a performance strategy. It removes one category of manual work; the rest of this article is the remainder.

  • It does not shrink your bundle. Code splitting, dependency pruning, and server rendering are still yours.
  • It does not virtualize a list. Ten thousand mounted rows stay ten thousand mounted rows.
  • It does not shrink images or fix a request waterfall.
  • It does not fix a state shape that makes every keystroke touch a global store.
  • It assumes you follow the Rules of React. How smoothly a rollout goes depends on the health of your codebase, which is the real reason to turn the lint rules on first.
  • Manual memoization stays available as an escape hatch. React recommends relying on the compiler for new code and using useMemo / useCallback where you need precise control — for example, when a memoized value is an effect dependency and you do not want the effect firing on every render. For existing code, React's advice is to leave current memoization in place or test carefully before removing it, since removing it can change compilation output.

Fix rendering

If the symptom is stalling, this is your section.

Memoization as a deliberate choice

React.memouseMemo and useCallback are tools for measured problems, not a default coding style. The useCallback reference on react.dev carries a section titled "Should you add useCallback everywhere?", and notes that React Compiler automatically memoizes values and functions, reducing the need for manual useCallback calls.

A workable rule for optimizing a React application in 2026:

  • Compiler on? Let it memoize by default, and reach for useMemo or useCallback only where you need control it cannot infer.
  • Compiler off? Memoize where the Profiler shows a real cost: an expensive computation, or a prop that breaks a memoized child's equality check.
  • Either way, memoization has its own cost: comparisons and retained references. Adding it everywhere trades one kind of slow for another and makes the code harder to read.

Inline functions: when they matter and when they do not

The old advice was "never define functions inline in JSX". As a blanket rule, it is wrong, and it sends teams on pointless refactors.

An inline arrow does create a new function on every render. That commonly matters in two situations: when the function is passed as a prop to a memoized child, where the new reference defeats the memo; and when it ends up in a dependency array, where the new reference re-triggers an effect. Everywhere else, on a plain onClick that does nothing expensive, the cost is negligible, and the inline version is more readable.

Fix the two cases that matter. Leave the rest alone.

useTransition and useDeferredValue

React 18 added two hooks for exactly the "typing feels stuck" symptom, and both are still the right answer.

useTransition lets you render a part of the UI in the background. The signature is const [isPending, startTransition] = useTransition(). The character appearing in the input is the urgent update, so it stays outside the transition. The filtered list of four thousand rows is the expensive consequence, so it goes inside. The input stays responsive, and isPending lets you show that something is happening.

useDeferredValue lets you defer updating a part of the UI: const deferredValue = useDeferredValue(value). Its documented uses are showing stale content while fresh content loads, and postponing the re-render of one part of the UI. Reach for it when you do not control the state update itself and only want to slow down what reads from it.

Neither hook makes the work cheaper. They can keep urgent updates responsive by making non-urgent rendering interruptible. Re-measure the interaction in field data to confirm that the change improved the experience.

State and context structure

State performance optimization in React is mostly about scope. Every consumer of a context re-renders when its provider receives a different value. A broad app-state context can therefore fan one update out to many unrelated consumers. Split context into several narrow ones, or move state down to the component that actually uses it, when the Profiler shows that fan-out.

Data shape matters too. Pick structures that match how you read and write. Objects keyed by ID suit entities you look up; arrays suit ordered collections you iterate. Normalizing related entities keeps updates cheap and stops the same record living in three places.

Picture a social feed that shows a user's posts, comments, and likes. Store both those and users as keyed entities and reference them by ID, and liking a post touches one record. Nest everything inside each post object instead, and the same click rewrites a large slice of state and re-renders far more of the feed than it should.

Be careful which library you reach for here. A lot of older React content still recommends Normalizr; its GitHub repository was archived by the owner on 20 March 2022, is read-only, and its last commit message is "end: no longer maintained". You rarely need a library for this at all. A keyed object and a selector will do.

Virtualize long lists

Virtualization (also called windowing) renders only the rows in view plus a small buffer, and recycles as the user scrolls. It is often the highest-leverage fix for a stuttering table when profiling shows that off-screen rows are being mounted, and it is the one technique that no amount of memoization substitutes for. Memoization makes re-renders cheaper; virtualization stops the components existing in the first place.

A caveat on library choice. Current react.dev does not recommend a specific virtualization library: react-window and react-virtualized are named in React's archived documentation, not in the current docs. So check the repository yourself before you commit, the way you should have checked Normalizr. Two options to evaluate are react-window and TanStack Virtual. Check each repository's recent releases, open issues, API fit, and maintenance activity before choosing one.

Before you install anything, confirm virtualization is the problem. Record a Profiler session while scrolling and look at how many row components mount. If the answer is "all of them", you have your fix.

Fix loading

If the symptom is a slow first load, memoization will not help you. These will.

Bundle size and what to measure it with

Start from your build output. Every modern bundler prints chunk sizes; a bundle analyzer turns that into a treemap where an oversized dependency is obvious in seconds. Then work down the list: remove packages nobody imports anymore, replace a heavyweight dependency with a smaller one or with platform APIs, and make sure tree shaking can actually eliminate what you are not using, which requires ES modules and no side effects in the import path. Compress with Gzip or Brotli at build or edge, and minify.

Note what changed in the tooling. React's creating-a-react-app page recommends starting with a framework (Next.js with the App Router, React Router v7, or Expo), and where it does list build tools, it names Vite, Parcel, and Rsbuild. Webpack and Create React App are not mentioned on that page at all. If your build is still Create React App on Webpack, that is not automatically a performance bug, but it is a maintenance decision worth putting on the roadmap.

Most of the weight is in the boring parts. Take a news site with a rich-text editor for authors. The editor's full plugin set ends up in the main bundle, and every reader downloads it, though only the editorial team ever opens the editor. Nobody wrote that requirement – it just accumulated.

Code splitting with lazy and Suspense

Code splitting breaks one large bundle into chunks along real boundaries: routes, or features behind a permission. Each chunk then loads only when something needs it.

lazy lets you defer loading a component's code until it is rendered for the first time, and you use it with a dynamic import()<Suspense> lets you display a fallback until its children have finished loading:

import { lazy, Suspense } from 'react';

const ProductPage = lazy(() => import('./ProductPage'));

<Suspense fallback={<Loading />}>
  <ProductPage />
</Suspense>

Take an e-commerce app with a large catalog. Without splitting, everything the product pages need (components, gallery logic, review widgets) ships to a visitor who only opens the homepage. Split by route, and that code arrives when someone navigates to a product, not before.

One tradeoff is a fallback that appears and vanishes almost immediately reads as a flicker, so consider whether the boundary belongs higher up. Another is splitting too finely, turning one large download into a waterfall of small ones, which on a high-latency connection is worse than what you started with.

Server-side rendering and React Server Components

These are two different layers, and a lot of older writing treats them as alternatives.

Server-side rendering produces HTML for a request so the browser has something to paint before your JavaScript arrives; the client then hydrates it. It mainly helps LCP and perceived load.

React Server Components are something else. React defines them as "a new type of Component that renders ahead of time, before bundling, in an environment separate from your client app or SSR server". Because they render before bundling, their code and their dependencies never reach the client at all. The data-fetching and formatting library you use in a Server Component adds nothing to what the browser downloads. RSC works both without a server, rendering at build time, and with one, rendering per request. They are stable in React 19.

For a team on an app-router framework, moving a data-heavy route to Server Components can materially reduce client JavaScript; compare the route bundle before and after. For a client-rendered SPA, it is a migration, not a quick fix, so budget it as one.

Images, fonts, and compression: the short version

Not React-specific, but the LCP impact is the same, so it belongs on the same list. Keep it short:

  • Images. Resize and compress at build time, and serve modern formats. Per web.dev, WebP and AVIF generally compress better than older formats and should be used where possible, with a JPEG or PNG fallback. Set explicit dimensions so images do not shift the layout and cost you CLS.
  • Fonts. Subset to the characters you actually use, and preload the one face that renders your LCP element.
  • Delivery. Gzip or Brotli on the wire, long cache lifetimes with content-hashed filenames, and a CDN close to your users.

A travel booking app full of destination photography is the standard case. If images dominate LCP, render optimization will not solve that bottleneck.

Throttling and debouncing without memory leaks

Some handlers fire far more often than the work behind them can keep up with — scroll, resize, mousemove, or an input that hits an API on every keystroke.

Throttling caps how often a function can run: at most one call per interval, useful for scroll and resize. 

Debouncing waits for a pause before running: useful for search-as-you-type, where you want the request after the user stops typing, not during.

Two things the older advice doesn't have:

First, where you create the function. A throttled or debounced function created at module scope is shared by every mounted instance of that component, so two search boxes on the same page cancel each other's pending calls. Create it inside the component and keep it stable across renders.

Second, cleanup. Cancel pending work in the effect cleanup so an outdated callback does not run after unmount or after the debounced function has been replaced. The pattern below keeps one debounced function across renders — but only while onSearch itself stays stable:

import { useMemo, useEffect } from 'react';
import debounce from 'lodash/debounce';

function SearchBox({ onSearch }) {
  const debouncedSearch = useMemo(
    () => debounce(onSearch, 500),
    [onSearch]
  );

  useEffect(() => {
    return () => debouncedSearch.cancel();
  }, [debouncedSearch]);

  return <input onChange={(e) => debouncedSearch(e.target.value)} />;
}

If the parent recreates onSearch on every render, useMemo builds a new debounced wrapper each time and the cleanup cancels whatever call was still pending. Either stabilize the callback where it is created, or hold the current callback in a ref and keep a single debounced wrapper that reads from it.

Lodash is one option here, not a React recommendation. The React docs do not prescribe a throttle or debounce library, and a dozen lines of your own will do the same job. Also check whether you need this at all: for a filter that runs locally, useDeferredValue often solves the same problem without a timer.

A performance budget and the order of work

React app performance optimization often stalls when the fixes land and nobody owns the guardrails. Use two controls: a field objective and build-time budgets. A field objective might keep p75 INP at or below 200 ms on the dashboard route. A build budget might cap initial JavaScript or enforce a repeatable synthetic threshold. They are related, but they are not interchangeable.

Track field INP per route in RUM and alert when it regresses. In CI, fail pull requests on deterministic signals such as route-level JavaScript bytes and reproducible Lighthouse CI checks. Set both from a measured baseline, not from an invented target.

What to fix in the first sprint

Six things, in this order. That is the short answer to how to optimize React app performance without clearing a whole quarter; effort and risk depend on the codebase, but this order keeps diagnosis ahead of larger changes.

  1. Baseline. Field data for the two or three routes that matter, plus a Profiler recording of the reported symptom. Write the numbers down.
  2. Turn on the compiler's lint rules. Upgrade eslint-plugin-react-hooks and use the recommended preset. The linter does not require the compiler to be installed, and the violations it finds are the same ones that would make a compiler rollout painful.
  3. Virtualize the worst list, if a list is the complaint.
  4. Split the two or three heaviest routes with lazy and Suspense.
  5. Delete dead dependencies the analyzer surfaced.
  6. Re-measure. Same routes, same conditions, same instruments.

What to fix in the second

Larger changes that need a review and a rollback plan:

  • Adopt React Compiler, incrementally (a directory at a time is a supported strategy), and remeasure after each stage.
  • Restructure state and context where the Profiler shows unrelated subtrees re-rendering together.
  • Apply useTransition and useDeferredValue to the interactions still missing the INP target.
  • Move a data-heavy route to Server Components, if your framework supports it and the bundle math justifies it.
  • Assets: image formats, font subsetting, cache headers.

Anything beyond this is a third sprint, and by then you should choose from measurements rather than a list.

How to keep it from regressing

Optimization decays. Someone adds a dependency, a new context wraps the tree, and in two months you are back where you started with a worse commit history.

Three habits hold the line. Keep the budget in CI, so a bundle jump fails a pull request. Watch field data per route rather than as a site-wide average, because a single slow route hides inside a good average. And add a repeatable performance check for the interaction you optimized; behavior tests alone will not reveal every re-render regression. Use automated tests to protect the functional behavior around the change; our guide to frontend testing frameworks and automation covers that setup. If your components live in a shared library, developing and testing them in isolation, as a React and Storybook setup does, makes that coverage easier to maintain.

When it is worth bringing in a front-end team

Most of this is work an in-house team can do, and the order above puts the cheap wins first for that reason. Three situations are genuinely different.

The first is when the measurement disagrees with itself: field data says one thing, but the Profiler says another.

The second is when the fix turns out to be architectural- in the state layer, the rendering model, or a framework migration- and you would rather it was done by someone who has done it before than by someone learning on your production app.

The third is the simplest: the roadmap is full, performance keeps losing to features, and there is no spare sprint to give it.

Merge built the front end for Edgeport, a cloud infrastructure SaaS, on React, Redux, TypeScript, and Next.js, so the practices here come from shipping and maintaining production React rather than from a tutorial. If you want a second pair of eyes on a slow app, starting with a diagnosis and an order of work rather than a rewrite, that is what our front-end development work looks like.

Whoever does it, keep the sequence of symptom, measurement, fix, re-measurement. React performance optimization done in that order keeps the work scoped and makes the result easier to defend.

POPOVER CROSS
call to action image

Design packages for your startup

Ideal for early-stage product UIs and websites.

See pricing
author

Co-Founder and CEO of Merge

My mission is to help startups build software, experiment with new features, and bring their product vision to life.

My mission is to help startups build software, experiment with new features, and bring their product vision to life.

You may be interested in

Let’s take this to your inbox

Join our newsletter for expert tips on growth, product design, conversion tactics, and the latest in tech.