DevgainsDevgainsDevgains
All articles

I Profiled 50 React Apps. Here's What Slows Them All Down

·5 min read

Over the last year I sat down with 50 production React apps — dashboards, marketplaces, internal tools, a couple of marketing sites bolted onto a SPA. Different teams, different stacks, wildly different code quality. I expected 50 different performance stories.

I got five. The same handful of problems showed up in almost every app, and once you've seen them a few times they become impossible to un-see. None of them are exotic. None require rewriting anything. Here's the pattern.

1. Shipping the whole app on the first route

The most common problem, by a wide margin, was a single giant JavaScript bundle. The home page imported a chart library, a rich-text editor, a date picker, and a PDF renderer — none of which the home page used. The browser downloaded, parsed, and compiled all of it before the user could do anything.

Code-splitting at the route level is the highest-leverage fix in React, and it's built in. React.lazy plus Suspense defers a component's code until it's actually rendered:

import { lazy, Suspense } from "react";
 
const Editor = lazy(() => import("./Editor"));
 
function Page() {
  return (
    <Suspense fallback={<Spinner />}>
      <Editor />
    </Suspense>
  );
}

In nearly every app I looked at, moving the three or four heaviest routes behind lazy() cut the initial bundle by 30–60%. The work wasn't deleting code — it was deferring code the first screen never needed.

2. Re-rendering the world on every keystroke

The second pattern: a piece of state lives too high in the tree, so changing it re-renders an enormous subtree. The classic version is a controlled search input whose value lives in a top-level component that also renders a 500-row table. Every keystroke re-renders the table.

React's render model is "re-render the component and its children when state changes." That's cheap until the children are expensive. The fix is usually one of: move the state down so fewer components depend on it, or split the urgent update (the input) from the expensive one (the list) with useDeferredValue.

Before you reach for memoization, ask where the state lives. A lot of "we need to memoize everything" problems disappear when you move state to the component that actually owns it. Colocation beats memoization.

3. New object and function identities every render

This one is subtle and everywhere. React decides whether to re-render a memoized child by comparing props with Object.is. If you pass a freshly created object, array, or arrow function as a prop, the comparison always fails:

// New object + new function on every render — memo on <Child> is useless.
<Child style={{ margin: 8 }} onSelect={(id) => select(id)} />

The inline style object and the inline arrow are new references each render, so any React.memo wrapper downstream never gets a chance to skip work. The fix is to hoist stable values out of render, or wrap callbacks in useCallback and values in useMemo — but only where a memoized child actually depends on them. Sprinkling them everywhere just adds overhead.

4. Rendering 10,000 rows the browser can't see

A surprising number of apps render every item in a long list into the DOM, even though the user can see maybe 15 at a time. Ten thousand <tr> elements means ten thousand sets of layout, paint, and event listeners — and a scroll experience that stutters.

The answer is windowing: render only the rows in the viewport plus a small buffer. Libraries like TanStack Virtual or react-window do this, but the concept matters more than the dependency. If a list can grow unbounded, it should be virtualized. I saw this single change take a 4-second table render down to under 100ms.

5. Measuring on the wrong machine

The last pattern isn't code — it's perception. Every team that thought their app was fast was testing on a top-tier laptop over office wifi. Their actual users were on mid-range Android phones over spotty mobile networks, where JavaScript parse and execute costs are several times higher.

Profile under realistic constraints. Chrome DevTools has CPU throttling and network throttling built in; use the 4x or 6x CPU slowdown and a "Slow 4G" profile. The React Profiler shows you which components re-render and how long they take. And capture field data with the web-vitals library so you're looking at your real p75, not your laptop's p50.

import { onLCP, onINP, onCLS } from "web-vitals";
 
[onLCP, onINP, onCLS].forEach((fn) =>
  fn((metric) => navigator.sendBeacon("/vitals", JSON.stringify(metric)))
);

The uncomfortable truth

None of these five are hard. There's no exotic profiling technique, no obscure browser flag. The reason they persist is that they're invisible on a fast machine, and they accumulate one reasonable-looking commit at a time. A lazy import skipped here, state hoisted "just for now" there, a list that was short during development and grew in production.

The teams with fast apps weren't smarter. They had a habit: they measured on a throttled device before shipping, and they treated bundle size and re-render counts as numbers worth watching, not afterthoughts. Performance isn't a project you finish. It's a thing you keep an eye on.

Takeaways

  • Split your routes. React.lazy + Suspense is the biggest single win; defer code the first screen doesn't need.
  • Watch where state lives. High state plus expensive children equals re-render storms. Colocate before you memoize.
  • Stabilize identities only where a memoized child depends on them — inline objects and arrows silently defeat React.memo.
  • Virtualize unbounded lists. Render what the user can see, not everything.
  • Measure on a throttled mid-range device and capture real p75 field data with web-vitals — your laptop is lying to you.
5 min read

Read next