React · advanced

React performance regresses in Chrome: connect profiler evidence to a render path

React performance regressions in Chrome are not single bugs; they are a render path going over budget. This guide connects Chrome DevTools Performance and React DevTools Profiler evidence to a specific commit or component change by enforcing an interaction budget, isolating the render path, and demanding a measurable before/after proof before a fix is accepted.

The symptoms

  • Chrome DevTools Performance recording shows a single user interaction (click, keystroke, route change) producing a long task over 50 ms on the main thread, with scripting dominating the flame chart instead of rendering or painting.
  • React DevTools Profiler shows a commit whose self-duration is large for one component, or a render that fans out to many children with non-zero actualDuration, even though props appear stable across the commit.
  • Interaction to Next Paint (INP) on real Chrome sessions degrades from a healthy under-200 ms baseline to over 200 ms at the 75th percentile, with the regression scoped to Chrome and not reproducible at the same level in Firefox or Safari.
  • User reports describe jank or dropped input that correlates with a specific feature area, while Lighthouse-style lab scores on the same page stay roughly flat, indicating a long-task interaction rather than a load-time issue.

Likely causes

  • A new dependency, memoization boundary removal, or context provider introduced in a recent commit increases the volume of work React schedules per render, and Chrome's V8 inlining plus concurrent rendering expose what Firefox or Safari amortize differently.
  • Inline object, array, or function props passed to memoized children break shallow equality and force re-renders of a subtree that the profiler can name but the developer did not intend to invalidate.
  • A useEffect dependency array widened to a new value, or an effect was upgraded from mount-only to running on every dependency change, producing synchronous work that blocks the browser's commit and input handling.
  • A synchronous state update inside an effect, event handler, or layout phase triggers an additional render pass that the Profiler records as a separate commit, doubling measured render time for the interaction.
  • Chrome-specific path: a layout thrash, forced reflow, or expensive selector evaluated inside a render or effect is more costly in Chrome's layout pipeline than in other engines, and surfaces as scripting plus layout time in the Performance panel.

First ten minutes

  1. 01Confirm the regression is Chrome-specific by re-running the same interaction in Firefox and Safari DevTools Performance and comparing long-task count and main-thread blocking time; if not Chrome-specific, table this guide and start from a cross-engine baseline.
  2. 02Open Chrome DevTools, capture a Performance recording with the JS profiler and "Advanced paint instrumentation" enabled, and reproduce the interaction exactly while Chrome throttling is set to its real device profile rather than the default 4x CPU slowdown.
  3. 03Open React DevTools Profiler, start a recording, perform the same interaction, and stop; rank commits by selfDuration and actualDuration to identify the component consuming the most render time per commit.
  4. 04Bisect the regression by checking out the last known-good release commit in a worktree and re-running the same recorded interaction to establish a measurable baseline before changing any code.
  5. 05Annotate the suspicious commit with the Chrome version, build hash, React version, and the precise interaction (selector, event, route) so the evidence is reproducible by another engineer.

Evidence to collect

  • Chrome DevTools Performance recording saved as a JSON profile containing task breakdown (scripting, rendering, painting), long-task markers, and the specific interaction marker.
  • React DevTools Profiler trace with ranked components by selfDuration and actualDuration, plus the props diff that triggered the render.
  • A Web Vitals INP field sample restricted to the same page and interaction, taken before and after the suspected commit, with sample size sufficient to compare the 75th percentile.
  • Commit delta showing changed files, especially context providers, hooks, component composition, and useEffect dependency arrays, captured with git diff --stat between the two builds.
  • Chrome version, React version, and React DevTools version reported inline in the recording header so results are not extrapolated across incompatible builds.

Where to look

  • React DevTools Profiler: the ranked components list in the "Ranked" tab, and the component tree flame chart for a single commit, where selfDuration and actualDuration are reported.
  • Chrome DevTools Performance panel: the "Main" thread section, specifically the "Scripting" and "Rendering" call-tree nodes under the marker corresponding to the user interaction event.
  • Source code boundary: the React component named at the top of the Profiler ranked list, and the immediately enclosing context provider, custom hook, and useEffect dependency array.
  • Commit boundary: the commit message of the suspect change, and the diff of any file that imports React, defines a provider, or wraps a memoized component, because render-path regressions usually originate at composition boundaries.
  • React official reference for useEffect: dependency array semantics, cleanup ordering, and the documented warning about omitting dependencies, since widening or narrowing this array is a common regression vector.

Diagnostic steps

  1. 01Compare the Chrome Performance recording between the last known-good and suspect builds; if the long task grows in the "Scripting" segment under the same interaction marker, the regression is in React render work rather than in layout or paint.
  2. 02Compare the React Profiler "Ranked" tab between the two builds; the component that grew in selfDuration is the candidate root cause, and its children that gained non-zero actualDuration reveal the blast radius.
  3. 03Inspect the candidate component's props at the slow commit using the Profiler "Why did this render?" panel; if an inline object, array, or function appears where a stable reference existed in the baseline, memoization is being silently broken.
  4. 04Examine the surrounding useEffect per the official useEffect reference: if a dependency was added, the effect now runs when it did not before, and Chrome's task scheduler will reflect this as a new recurring task; if a dependency was removed, the documented warning indicates the effect may be reading stale values.
  5. 05Cross-check whether the work is bounded by an interaction budget: measure scripting time for the interaction in the baseline and suspect builds, and require the suspect build to stay within the same budget before approving any change.

Common mistakes

  • Optimizing the component with the highest actualDuration before identifying the one with the highest selfDuration, which causes work to be moved around the tree rather than removed.
  • React.memo or useMemo applied without first verifying the reference instability in the Profiler "Why did this render?" panel, leading to false confidence that the render path is now cheap.
  • Reading the Profiler in production builds where React strips component names and disables the "Why did this render?" panel, which makes the evidence impossible to attribute to a specific component.
  • Confusing the Chrome Performance "Rendering" segment with React render work; React render is recorded under "Scripting", and conflating the two leads to misdirected layout or paint fixes.
  • Trusting a single low-quantile INP field sample as evidence of a regression or a fix; the 75th percentile over a stable interaction is the minimum bar, and small samples will produce false signals.

Safe fixes

  • If the Profiler evidence shows an inline object, array, or function prop breaking memoization on the named component, hoist the allocation out of the render body or memoize it with useMemo / useCallback, and re-record the Profiler to confirm selfDuration returns to baseline.
  • If a useEffect dependency array was widened in the suspect commit and the effect now runs on the relevant interaction, narrow the dependency array to the documented minimum per the useEffect reference, and confirm via Performance recording that the long task disappears.
  • If a context provider wraps a large subtree and the Profiler shows the subtree re-rendering on unrelated state changes, split the provider into a stable value provider and a narrow dispatch provider, and re-measure actualDuration on the previously affected children.
  • If synchronous state updates during the interaction produce a second commit, defer the update to a transition or move it to an event handler that runs after the current task, then re-record to confirm the Profiler shows one commit instead of two.
  • If Chrome-specific layout cost dominates and the Profiler shows React work unchanged, fix the layout or selector at the source rather than masking the symptom with memoization, and re-run the Performance recording to confirm the "Rendering" segment shrinks.

Prove the fix

  1. 01Re-run the Chrome DevTools Performance recording on the same hardware profile and interaction, and verify the long task for the interaction has returned to within 10 percent of the last known-good baseline scripting time.
  2. 02Re-run the React DevTools Profiler on the same interaction, and verify the named component's selfDuration and the blast radius children's actualDuration are within the same tolerance of the baseline.
  3. 03Confirm the Web Vitals INP 75th percentile on the affected page returns to the baseline band after the change is deployed, using the same interaction definition and a comparable sample size.
  4. 04Verify the Profiler "Why did this render?" panel shows the previously unstable prop as a stable reference for the interaction, so the fix is structural rather than incidental.
  5. 05Repeat the recording on Firefox and Safari DevTools to confirm no regression was introduced for the other engines while Chrome was being optimized.

Prevention and next steps

  • Adopt an explicit interaction budget per user-facing interaction in the Performance Style Guide, and treat any commit that increases the recorded scripting time for that interaction as a blocking review item.
  • Require a React DevTools Profiler recording before and after any change to a provider, custom hook, or top-level component, attached to the pull request so the evidence is part of the review, not a post-hoc artifact.
  • Add a CI guard that runs a scripted interaction in Chrome and fails the build if the median scripting time for that interaction exceeds the recorded budget, gated on a stable hardware profile.
  • Document the useEffect dependency rules from the official useEffect reference in the project's contributing guide, and review dependency array changes with the same rigor as public API changes.

Safe commands and checks

git checkout -b perf/budget-<interaction> <last-known-good-sha>
git diff --stat <last-known-good-sha>..HEAD -- '*.tsx' '*.ts' '*.jsx' '*.js'
git log --oneline <last-known-good-sha>..HEAD -- '*.tsx' '*.jsx'
grep -rn "useEffect" <path-to-suspect-component>
grep -rn "React.memo\|useMemo\|useCallback" <path-to-suspect-component>
npm run typecheck -- --noEmit
npm run test -- --watchAll=false <interaction-test-file>