React Performance in the Real World
How to actually triage performance in a production app — finding the right problem before reaching for the right tool.
All the patterns in this series are answers to specific questions. Before applying them, you need to find the right question. Optimizing the wrong thing is worse than not optimizing at all — it adds complexity without moving the metric that matters.
Here's how I think about approaching performance in a real app.
Find the Highest-Agony Bottleneck First
A useful mental model for prioritization: agony = slowness × traffic.
A page that takes 3 seconds to load but only 10 people visit per month is low agony. A search input that lags 200ms on every keystroke for thousands of daily users is high agony. Start there.
Some areas that are consistently high agony:
- Initial load — if the app takes more than a second to become interactive, fix that before anything else
- Frequent interactions — anything the user does repeatedly: typing, clicking filters, toggling items
- The things that feel sticky — load a page, use it for 5 minutes, notice it getting gradually slower. That's usually a memory or event listener leak, not a render problem.
The less obvious problems are often the ones nobody owns. Large enough apps have multiple teams, and the areas of most pain tend to fall between team boundaries. Getting alignment on who fixes a cross-cutting problem is sometimes the harder job.
Use the Tools Before Reaching for the Optimizations
The two tools that actually tell you what's happening:
React DevTools — Highlight Updates: Turn on "Highlight updates when components render." Anything flashing when it shouldn't be is a problem. Anything flashing slowly is worth investigating. Anything flashing at 1.1ms is not worth your time — leave it alone.
Chrome DevTools — Performance tab: Hit Record, interact with the app, stop. Look at:
- CPU usage — sustained 100% during an interaction is a red flag
- Long tasks — Chrome puts a red flag on anything exceeding the 16.6ms frame budget
- Event handlers — if a click handler takes 800ms, that's where to start
The insight from the tools usually tells you what is slow. The patterns in this series tell you how to fix it.
Match the Tool to the Problem
| What you observe | Likely fix |
|---|---|
| Component re-renders when its props/state didn't change | Push state down, or React.memo + useCallback/useMemo |
| Expensive calculation re-runs when inputs are the same | useMemo |
| Typing/clicking makes the whole UI feel sticky | useTransition |
| Results lag but input feels fine | Already using transitions correctly |
| Slow network response feels jarring | useOptimistic |
| Too many DOM nodes (thousands of list items) | Virtualization / windowing |
| Initial load is slow | Code splitting, Suspense for data fetching, server rendering |
The table oversimplifies, but it's a useful first filter. If the problem isn't in the table, measure more before guessing.
Don't Memoize Everything
The most common mistake after learning about React.memo, useCallback, and useMemo is applying them everywhere preemptively. It feels productive. It's usually harmful.
React.memo prevents renders when props are the same — but if a component's state changes (via a hook), it still re-renders regardless of the memo wrapper. Wrapping everything in memo and having it silently not work is confusing to debug.
More importantly: memoization on its own can break things. If a grandchild component needs an update that should have cascaded from a parent, a React.memo in the middle can silently swallow it. A slow app that works correctly beats a fast app that shows stale data.
React Compiler is the principled answer to "why can't memo just be automatic." It analyzes the code at build time and understands the dependency graph — something runtime React.memo can't do. For greenfield projects, enabling it from the start is worth considering. For existing codebases, the incremental migration path is worth reading about in the React Compiler post.
The Cost of Over-Engineering
There's a version of this that I've seen go wrong: spending a week memoizing everything in a component that turns out nobody uses, or adding useTransition to a search that runs in 2ms and was never a problem.
The goal is shipping software that works well for users — not having the most theoretically optimal render tree. If a re-render completes in under a millisecond, it's invisible to users and not worth your time.
A practical standard: if a user-initiated interaction consistently takes more than 100ms from click to visual feedback, investigate it. Below that, most users won't notice. Above it, the tools will show you exactly where the time is going.
Applying This Progressively
For a new codebase:
- Enable React Compiler from the start — get memoization coverage without manual work
- Use
useTransitionfor any interaction that drives an expensive UI update - Use
useOptimisticfor mutations that should feel instant - Profile before shipping if anything feels slow
For an existing codebase:
- Find the highest-agony bottleneck with DevTools
- Apply the targeted fix — don't refactor the whole component
- Measure before and after
- Move to the next bottleneck
The hardest truth about React performance: most of the time, the biggest wins come from structural decisions — where state lives, how the component tree is shaped — not from sprinkling optimization hooks. Push state down before adding memoization. Memoize before adding transitions. Transitions before virtualization.
The order matters because each step is riskier and more complex than the previous one. Start with the simplest intervention that could possibly work.
Practice what you just read.
Keep reading
Enjoyed this? Get more like it.
Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.