I Profiled 50 React Apps. Here's What Slows Them All Down
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+Suspenseis 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.

