Skip to content
Back to blog

React: how to make your app more performant

Slow React apps are rarely slow everywhere. Usually one or two causes account for most of what users feel, and the rest is noise. The skill is finding those causes before you touch any code.

This is the order I work in, and the order I would explain it in an interview: measure, then fix the biggest wins first, then check the numbers again.

Measure before you change anything

Guessing is the most common performance mistake. People wrap everything in useMemo because it feels responsible, and the app is exactly as slow as before, with more code.

I use four tools, each for a different question. Lighthouse tells me how a page loads in a lab setting. The Chrome Performance panel shows what the main thread is doing and where long tasks are. The React DevTools Profiler shows which components rendered, why, and how long each took. Field data, such as the Chrome UX Report or your own real-user monitoring, tells me what actual users on actual devices experience.

Lab and field data disagree often. A fast laptop on office wifi hides problems that a mid-range phone on a train will show. When they conflict, I trust the field data and use the lab tools to reproduce the problem.

The loop I follow
  1. MeasureProfiler, Lighthouse, field data
  2. Find the bottleneckOne cause, not a list
  3. Fix one thingSmallest change that works
  4. Measure againKeep it only if it moved

One change per pass. If you change three things, you cannot tell which one helped.

Ship less JavaScript: split the bundle

For load performance, bundle size is usually the biggest lever. Every kilobyte of JavaScript has to be downloaded, parsed and executed before the page is interactive, and that cost is highest on slow phones.

Start by looking at what is in the bundle. A bundle analyzer shows the large dependencies, and it often turns up a charting library or rich text editor that loads on every page but is used on one. Those are the best candidates for lazy loading.

In plain React, React.lazy with Suspense loads a component only when it first renders. The import becomes a separate chunk that the browser fetches on demand.

Lazy.tsx
import { lazy, Suspense } from "react";

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

export function Dashboard() {
  return (
    <section>
      <h1>Dashboard</h1>
      <Suspense fallback={<p>Loading chart...</p>}>
        <RevenueChart />
      </Suspense>
    </section>
  );
}

In Next.js, next/dynamic does the same job and lets you set a loading placeholder. Keep the placeholder the same size as the final content, or you trade a bundle problem for a layout shift problem.

Dynamic.tsx
import dynamic from "next/dynamic";

const RevenueChart = dynamic(() => import("./RevenueChart"), {
  loading: () => <div style={{ height: 320 }} aria-busy="true" />, 
});

export default function Page() {
  return <RevenueChart />;
}

Route-level splitting already happens for free in Next.js. The extra wins come from components below the fold, modals, tabs the user has not opened, and anything behind a click.

JavaScript shipped on first load
  • Before splitting480 KB
  • After splitting190 KB

Illustrative numbers to show the shape of the win, not a benchmark. Your results depend on your app and your dependencies.

Virtualize long lists

Rendering 5,000 rows means creating 5,000 sets of DOM nodes, even though the user sees maybe 20. Virtualization renders only the rows near the viewport and fakes the rest with spacing.

Plain list vs virtualized list

Try this: scroll the list at the bottom, then drag the slider to make it bigger. Only a handful of rows ever exist.

Plain list: DOM nodes rendered5,000 nodes
Virtualized list: DOM nodes rendered13 nodes

The plain list would create 5,000 rows. It is not rendered here because it would freeze this page. Scrolling 5,000 items still renders at most 19 rows, so the virtualized list stays flat as the list grows.

Rendering items 1 to 13 of 5,000 (13 rows in the DOM)

Item 1
Item 2
Item 3
Item 4
Item 5
Item 6
Item 7
Item 8
Item 9
Item 10
Item 11
Item 12
Item 13

I reach for a library rather than writing this myself. @tanstack/react-virtual gives you a hook, and you render the items it tells you to.

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

export function Rows({ rows }: { rows: string[] }) {
  const parentRef = useRef<HTMLDivElement>(null);
  const virtualizer = useVirtualizer({
    count: rows.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 36,
  });

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

Virtualization has costs. Browser find-in-page will not see rows that are not rendered, and accessibility needs care. For lists of a few hundred simple items, pagination or plain rendering is often fine.

Optimize images

On many pages the Largest Contentful Paint element is an image. Serving a 3000 pixel wide photo to a phone is a very common and very fixable cause of a slow LCP.

next/image resizes images, serves modern formats where the browser supports them, lazy loads by default and reserves space so the layout does not jump. Two props matter most. sizes tells the browser how wide the image will actually be, so it picks a sensible file. priority disables lazy loading and should go on the one image that is your LCP element, and only that one.

Hero.tsx
import Image from "next/image";

export function Hero() {
  return (
    <Image
      src="/images/hero.jpg"
      alt="Team working at a whiteboard"
      width={1600}
      height={900}
      sizes="(max-width: 768px) 100vw, 1200px"
      priority
    />
  );
}

Images below the fold should stay lazy. Marking everything as priority removes the benefit, because the browser then fetches all of it at once.

Avoid unnecessary re-renders

Re-renders are cheap by default, and React is fast at them. They become a problem when a state change near the top of the tree re-renders a large subtree, or when one render does expensive work.

My first fix is almost never memo. It is moving state closer to where it is used. If only one input needs the search text, the rest of the page should not re-render on every keystroke.

Before
function Page({ products }: { products: Product[] }) {
  const [query, setQuery] = useState("");

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ExpensiveGrid products={products} />
    </>
  );
}
After
function SearchBox() {
  const [query, setQuery] = useState("");
  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

function Page({ products }: { products: Product[] }) {
  return (
    <>
      <SearchBox />
      <ExpensiveGrid products={products} />
    </>
  );
}

If the search text must filter the grid, a related option is useDeferredValue or useTransition, which let React keep typing responsive while the heavy update runs at lower priority.

Use memo, useMemo and useCallback when the Profiler shows a specific component rendering often with unchanged props, and the render is costly. Memoization adds comparison work and code, so it should pay for itself with a measurement.

The React Compiler changes the calculus. It memoizes automatically at build time, so in a project that uses it, hand-written memoization matters less. It still cannot fix state that lives in the wrong place or a component that does too much work, so the structural fixes above still apply.

Know the Core Web Vitals

Core Web Vitals give a shared vocabulary for user experience, and they are what interviewers and Google both ask about. There are three, and each has a threshold for a good score, usually judged at the 75th percentile of real users.

  • LCP, Largest Contentful Paint: how long the main content takes to appear. Good is 2.5 seconds or less. Fixes: smaller bundles, optimized and prioritized hero image, faster server response.
  • INP, Interaction to Next Paint: how quickly the page responds to clicks, taps and keys. Good is 200 milliseconds or less. Fixes: break up long tasks, reduce re-render cost, defer non-urgent work.
  • CLS, Cumulative Layout Shift: how much content jumps around. Good is 0.1 or less. Fixes: set image dimensions, reserve space for late content, keep placeholders the same size as the real thing.

Core Web Vitals checker

Example numbers, not measurements of this page. Try this: drag LCP below 2.5 and watch the rating change.

3.2
Needs work
350
Needs work
0.15
Needs work

Start with LCP. Shrink the hero image, add priority to it, and lazy load code the first screen does not need.

Notice that the fixes in this post map directly onto these three. Splitting and image work improve LCP, render work improves INP, and sized placeholders protect CLS.

Interview answer in 30 seconds

I would not start by changing code. I start by measuring with the React DevTools Profiler, Lighthouse and the Performance panel, and I check field data to see what real users get. Then I fix the biggest cause first. Usually that is JavaScript size, so I split the bundle with lazy loading, then handle images with next/image, virtualize long lists, and move state down before reaching for memoization. I track LCP under 2.5 seconds, INP under 200 milliseconds and CLS under 0.1, change one thing at a time, and measure again to confirm it helped.

Key takeaways

  • Measure first, change one thing at a time, and measure again.
  • Cut JavaScript on first load with React.lazy, Suspense or next/dynamic.
  • Virtualize long lists and optimize images, with priority only on the LCP image.
  • Colocate state before reaching for memo, and memoize only what you measured.
  • Target LCP 2.5 s or less, INP 200 ms or less and CLS 0.1 or less.