Nearly every complex React product I’ve been brought into — as an employee, a contractor, or an outside pair of eyes — arrives with the same sentence: “the app feels slow, and we’re not sure why.” What follows is usually a list of suspects — React itself, the state library, a component someone wrote two years ago, the design system — and, more often than teams like to admit, a proposal to rewrite one of them. Almost none of it is backed by a measurement.
What follows is the diagnostic method I actually use: a fixed order of investigation that separates what users feel from what profilers show, and architectural causes from local ones. It is not a list of tips. Tips assume you already know where the problem is. A diagnostic exists because you don’t.
Why teams guess instead of profiling
Guessing is not laziness — it’s structural. Performance degrades gradually, one reasonable pull request at a time, so no single change is obviously to blame. Profiling is nobody’s job description, so it happens sporadically, on a developer machine, against a seed database that looks nothing like a real enterprise account. And every engineer has seen a different symptom, so every engineer has a different theory. The debate feels like analysis. It isn’t.
The cost of guessing is concrete. It looks like memoization sprinkled across the codebase “to be safe,” each useMemo a small tax on readability and a false signal of progress. It looks like a state-management migration that consumes a quarter and changes nothing users can feel. At the expensive end, it looks like a rewrite pitch built on frustration rather than evidence. I have watched teams spend more engineering hours debating performance than measuring it — which is strange, because measuring is cheaper and it ends the debate.
The order of investigation
The order matters because each layer can masquerade as another. A data-loading waterfall feels like slow rendering. A memory leak feels like the app “getting worse over time” for reasons nobody can reproduce in a fresh tab. Startup bundle cost feels like a slow framework. If you start with a tool instead of a symptom, you will find something — profilers always find something — and optimize a layer that was never the bottleneck.
- User-visible symptoms first. Name the exact interactions that feel slow — which screen, which action, which hardware, which account size. “Typing lags in the editor” and “the dashboard takes seconds to appear” are different diseases that need different instruments.
- React Profiler flame analysis. Record the slow interaction and read the commits: which components rendered, how long each commit took, and — more important — why they rendered. Unstable prop identities, context values recreated every render, and state that lives higher in the tree than the interaction that changes it account for most of what I find here.
- Memory and heap snapshots. Take a snapshot, repeat the suspect interaction, snapshot again, and diff. Detached DOM nodes, retained closures, and listeners that outlive their components show up as sessions that degrade over hours — the class of problem your users hit and your quick local test never will.
- Bundle and network cost. What actually ships to the browser, which routes pull which chunks, what executes before the first meaningful interaction. This is where route-level code splitting either exists or doesn’t.
- Data-loading waterfalls. Read the network panel horizontally, not vertically: sequential requests where each awaits the previous, refetches triggered by focus or remounts, endpoints returning ten times the payload the screen consumes.
Architectural causes versus local ones
The most valuable output of a flame analysis is not “this component is slow.” It is the distinction between local causes and architectural ones. A local cause is contained: an expensive component missing a memoization boundary, a thousand-row table that should be virtualized, a sort running inside a render. These are day-scale fixes with day-scale risk.
An architectural cause looks different in the profiler: the damage is distributed. A state shape where every keystroke updates a provider near the root, so half the tree reconciles on every input event. A context whose value object is rebuilt each render, invalidating every consumer. Components subscribed to whole entities when they read two fields. Patch an architectural cause with local fixes and you get whack-a-mole — memo the loud component this sprint, meet its three siblings next quarter.
Building telemetry dashboards at Zenseact made this distinction vivid for me. High-frequency vehicle telemetry will bury any component-level optimization; the architectural decision was to take the dense rendering off React’s reconciliation entirely — WebGL for the data surfaces, the DOM reserved for controls, with D3 handling scales and interaction. No quantity of memoization would have arrived there. The boundary itself was the fix, and finding it required knowing which kind of cause we were looking at.
Deciding fix order: user impact against blast radius
A diagnostic that produces an unranked list of findings is a report, not a plan. I rank every finding on two axes: how much users feel it, and how much of the codebase a fix touches. High-impact, low-blast-radius fixes ship first — they buy measured wins and organizational trust while feature delivery continues. Architectural causes are high blast radius by definition, so they become incremental migrations along seams the diagnostic identified, never a stop-the-world effort.
The other discipline is where you measure. Leading frontend delivery at MEGOGO, the platforms that mattered included Smart TVs with a fraction of a laptop’s performance and unforgiving media stacks — so targets were anchored to the weakest device we actually served, not the demo machine. Most teams don’t ship to televisions, but the principle transfers directly: a fix is not done when it merges. It is done when the interaction is measurably better on the hardware and account sizes where the complaint originated.
When the architecture is fine and the data layer isn’t
A diagnostic has to be allowed to reach unwelcome conclusions, and the most common one is this: rendering is fine, the component architecture is fine, and the product still feels slow because of how data arrives. Six sequential round trips where each request waits on the previous. A refetch on every window focus. A screen that needs a name and a status, served by an endpoint that returns the full object graph. In the large enterprise React applications I’ve worked on, the interactions users actually complain about are bounded by data shape and request sequencing at least as often as by render cost.
Teams resist this diagnosis for an organizational reason, not a technical one: the fix crosses the frontend–backend boundary. Reshaping an endpoint, moving a join server-side, or defining a real cache policy requires two teams to agree, whereas memoizing components requires nobody’s permission. But no amount of rendering discipline compensates for a waterfall. If the profiler says commits are cheap and the network panel shows a staircase, the honest finding is that the frontend is not the problem — and a diagnostic that can’t say so is a sales document.
Why a rewrite is usually the wrong output
Rewrite proposals appear precisely where diagnosis is absent. A rewrite is what a team reaches for when it cannot name its causes — the hope that a new codebase will not contain the old problems. But a diagnostic produces named causes, and named causes are almost always addressable incrementally: a state boundary moved, a context split, a list virtualized, an endpoint reshaped, a chunk deferred. Each change is measurable on its own, ships in a normal sprint, and carries bounded risk.
A rewrite, by contrast, carries the same team assumptions that produced the original problems, adds a long stretch where the old and new systems both exist, and suspends the feature delivery the business is paying for — all before it proves anything. There are legitimate rewrites; they are rare, and they are conclusions you earn with evidence, not starting points. Again and again in this work I have recommended incremental paths over replacements, for the simple reason that the profiler kept pointing at specific, fixable things.
What to do next
You can run this diagnostic yourselves: one engineer, one week, the order above, on production-realistic data — ending in a written list of causes ranked by user impact and blast radius. If nobody has that week, or the findings will be contested and need to arrive with outside authority, this is exactly the shape of my Product & Architecture Diagnostic: a few days inside the real system, a profiler instead of opinions, and a remediation plan your team can execute without stopping delivery.