React · intermediate
React page slows over time: distinguish retained objects from growing work
Some React pages are not slow at first; they become slow as the user interacts with them. The degrade-over-time symptom is almost always one of two things: retained objects that prevent garbage collection, or work that grows because state, context, or subscriptions keep expanding. Engineers must distinguish between the two before changing code, because the fixes are different and a guess can mask the real cause. This guide frames the decision, then walks through the evidence needed to separate the two classes.
The symptoms
- •After a few minutes of clicking, scrolling, or tabbing, interaction latency climbs steadily from a low baseline until the page feels janky. A hard reload returns the page to its initial speed, which is the strongest clue that state is accumulating rather than a one-time cold start.
- •Heap snapshots taken at first paint and after the same workload show a growing detached DOM tree, retained arrays, or listener maps whose size tracks interaction count rather than rendered element count.
- •Performance traces show component renders or effect runs whose count grows with the number of user actions, not with the size of the visible DOM, indicating per-interaction work is being added instead of replaced.
- •Reopening a modal, route, or dropdown a second time runs more work than the first open, even though the rendered output is identical and props have not changed.
- •DevTools warns about state update from an unmounted component, or a profiler shows components still mounting after navigation away, both of which point to subscriptions and timers that were never cleaned up.
- •Memory pressure in the tab rises monotonically over a session even when the user is only performing normal navigation, which is the defining signal of retention rather than steady-state work.
Likely causes
- •Per-interaction state stored in a parent, context, or external store that appends entries instead of replacing them, so each click adds a record that the next render must iterate.
- •Effects that subscribe to event sources or timers and return a different cleanup function on every render, or omit the cleanup function entirely, so listeners accumulate on the same target.
- •Caches keyed by object identity where keys are recreated every render, defeating the cache and forcing it to grow on every state change.
- •Refs that hold DOM nodes or large objects after the component they belong to unmounts, preventing the surrounding tree from being collected.
- •Inline objects, functions, or arrays passed as context value or as dependencies, which both grow the dependency diff and re-run downstream effects on every parent render.
First ten minutes
- 01Record the exact interaction sequence that produces the slowdown: which buttons, how many clicks, how long until the page feels slow, and what reload restores the baseline. Without this script, you cannot reproduce the curve you are trying to explain.
- 02Open the React Profiler in development and record the session on "supports Profiling" build. Confirm whether renders and effect durations climb with each interaction or stay flat. A flat render profile with a climbing heap rules out growing work and points to retention.
- 03Open the browser's memory tab and capture two heap snapshots using a documented supported tool path: one at idle immediately after first paint, one after the slowdown reproduces. Compare the two snapshots and look at the size delta of the largest detached trees and retainer chains.
- 04Check the Performance trace for the same interaction repeated N times. If the self-time of the same handler grows with N, the handler is doing accumulating work; if it is flat while heap climbs, listeners are leaking.
- 05List the components mounted on the route that exhibits the issue, and for each, note whether it registers a subscription, timer, or third-party listener. This narrows the search space before reading code.
Evidence to collect
- •Interaction script: enumerated steps with click counts, expected response, and the time at which latency first becomes user-visible. Required so the bug is reproducible independent of the original reporter.
- •Two heap snapshots with the same interaction script applied between them, plus the delta view sorted by retained size. The shape of the growth tells you whether the leak is detached DOM, an array, or a closure.
- •Profiler timeline covering the same script, with commits and effect timings visible. Compare the Nth commit to the first to see if component work also grows.
- •List of all useEffect calls on the affected route, including each one's dependency array and the cleanup it returns, plus a note on whether the cleanup identity is stable across renders.
- •List of all context providers on the route, with the stability of the value prop across renders and whether the value is a new object literal each render.
Where to look
- •At the React component boundary: the dependency arrays of useEffect, useMemo, and useCallback, and whether the cleanup function returned by an effect is the same function across renders.
- •At the context boundary: providers whose value prop is constructed inline in the parent render, since this creates a new value object on every render and re-runs every consumer's effects.
- •At the state and store boundary: reducers, Zustand slices, Redux stores, and any external store consumed via subscriptions, where append-only update patterns are easiest to introduce.
- •At the subscription boundary: addEventListener, IntersectionObserver, MutationObserver, ResizeObserver, WebSocket, EventSource, and setInterval or setTimeout calls, where missing or unstable cleanup is the most common leak source.
- •At the DOM boundary: refs that capture nodes whose owning component has unmounted, including portal targets and third-party widget mount points.
Diagnostic steps
- 01Reproduce the slowdown with a fixed interaction script and measure the time-to-interaction-jank. If a hard reload resets the curve, the problem is per-tab retained state, not a deploy or server-side regression.
- 02Capture a baseline profile and a post-interaction profile. Decide whether total render time and effect time grow with interaction count. If yes, the dominant cause is growing work; if no, the dominant cause is retention.
- 03If retention is dominant, take two heap snapshots and compare retainer chains. A growing detached DOM tree points at unmounted components holding refs; a growing array or map points at a store that appends entries; a growing closure points at a listener registered without cleanup.
- 04If growing work is dominant, instrument the suspect handler with a console.time span around the suspected hot path and repeat the interaction. Self-time that grows linearly with N indicates an O(N) operation over a collection that itself grows, which is the same root cause as retention expressed through work.
- 05For each subscription found on the affected route, verify the effect returns a cleanup that calls removeEventListener, disconnect, clearInterval, or the equivalent, and that the cleanup function reference is stable across renders. Missing cleanup is the most common reason listeners accumulate.
- 06For each context provider, check whether the value prop is memoized. An unmemoized value object is a strong candidate when the slowdown correlates with parent re-renders rather than with interaction count.
- 07Confirm the working hypothesis by applying a minimal change in a scratch branch, repeating the script, and observing whether the chosen metric (heap size or per-interaction self-time) flattens. Without this confirmation, the change is a guess.
Common mistakes
- •Fixing the wrong layer: rewriting a reducer to slice a smaller array when the real problem is a leaked event listener, so the heap continues to grow and the symptom is blamed on the next layer up.
- •Trusting a single metric: heap size alone can rise during normal caching, and render time alone can rise during legitimate work; only the combination of both, plus a delta, distinguishes the two failure modes.
- •Reading the dependency array as a performance knob: omitting dependencies to stop re-runs is a correctness bug, not a performance fix, and it can mask the original leak while introducing stale state.
- •Conclusion drift from production-only artifacts: a heap snapshot taken in a deployed build mutated by source maps can hide detached DOM unless the relevant retainers are expanded; treat any single snapshot as suggestive, not conclusive.
- •Stopping at the first leak: a page can leak both a subscription and a ref, and fixing only one will leave the curve partly improved and partly puzzling.
Safe fixes
- •If a subscription is leaking, return a cleanup function from the effect that calls the matching teardown, and keep the cleanup identity stable across renders so the effect actually unsubscribes. This is appropriate only after the leak is confirmed in the snapshot retainer chain.
- •If a store is appending entries on every interaction, replace the append with a fixed-size ring or a map keyed by a stable identifier, so each interaction overwrites an existing entry rather than adding a new one. Appropriate only after the snapshot delta shows growing collections.
- •If a context value is recreated on every render, memoize the value object so consumers only re-render when the underlying data actually changes. Appropriate only after the profiler shows consumer renders tracking parent renders rather than interaction count.
- •If a handler does O(N) work over a growing collection, hoist the collection into a ref or a stable structure and have the handler read from it, so the cost does not scale with retained data. Appropriate only after the handler's self-time is observed to grow with N.
- •If a ref holds a node after unmount, clear the ref in the effect cleanup so the node can be collected. Appropriate only after the snapshot shows a detached DOM retainer attributable to the ref.
Prove the fix
- 01Repeat the original interaction script and capture two new heap snapshots. The size delta between the post-script and pre-script snapshots must be within a small noise band of the pre-fix baseline, and must not grow when the script is run a second time. A snapshot that still grows on each script run proves the fix did not address the dominant retainer.
- 02Repeat the original interaction script and capture a new profiler timeline. The total render time and effect time for the Nth interaction must be the same as for the first, within a small noise band. A handler whose self-time still grows proves the work side was not addressed.
- 03Run a longer harness that executes the interaction script five times in succession. Both heap delta and per-interaction self-time must remain flat from the first run to the fifth. Improvement that does not survive extension is improvement, not a fix.
- 04Hard reload the page and confirm the post-reload curve matches the pre-fix post-reload baseline, so the fix is not relying on a stale tab state. A fix that only works after a reload has not closed the leak.
Prevention and next steps
- •Adopt a code review rule that every effect returning a subscription also returns a stable cleanup, and that every context value is memoized when children are non-trivial. This is a structural guard, not a measurement.
- •Add a performance regression check to CI that runs a fixed interaction script and asserts that the heap delta and the per-interaction handler self-time remain within a band, so a regression is caught before it reaches users.
- •Periodically run a long-session smoke test in a documented environment and capture a heap snapshot at the end, so detached trees and accumulated listeners surface during development rather than in production.
- •Keep a single source of truth for subscription helpers, so a missing teardown is a code review problem rather than a per-component discipline problem.
Safe commands and checks
node --inspect-brk=<port> for a Node-side render harness, where <port> is a free port obtained by checking the process listing without a fixed assignment. Use only against a local development build, not against any private URL.
chrome://inspect in a Chromium-based browser to attach the DevTools to a running tab, then use the Memory tab to capture heap snapshots and the Performance tab to record a trace. The exact keystrokes are documented in the browser's help, not invented here.
react-devtools-profiler for capturing a profiling build of the affected route, with the interaction script timed against the profiler's own commit markers. The profiler's own documentation defines the supported build flags.
console.time("<label>") and console.timeEnd("<label>") around a suspected handler body, where <label> is a descriptive string, to measure per-interaction self-time when the profiler is not available.
performance.measure("<label>", { start: "<mark>", end: "<mark>" }) using user marks placed at the boundaries of a handler, with the result read via performance.getEntriesByName. This is a portable, profilable measurement that does not require any server.
performance.memory.usedJSHeapSize sampled at fixed points in the interaction script, recorded alongside the script timestamp, to plot a heap curve without leaving the page. This is a supported API in Chromium-based browsers and is a coarse but useful signal.