React · advanced

React memory-retention checklist

Repeated interaction with a React component retains work it should have released: closures, subscriptions, timers, and references survive unmount or re-render. This checklist gives advanced engineers an evidence-driven triage path grounded in React's documented effect semantics, separating user-perceived jank from measurable retention so fixes can be proven rather than asserted.

The symptoms

  • Memory usage of the host page climbs monotonically as the user navigates away from a screen and returns, never returning to a prior baseline in DevTools' Performance Memory tab.
  • Heap snapshots taken after unmount still show detached DOM trees, ArrayBuffers, or large object graphs reachable only from closures that the component should have released.
  • Event listeners or intervals continue firing after a component is unmounted, observable as duplicate analytics events, continued setState warnings, or background work that does not stop.
  • Profiler commits show effect dependencies that re-run unnecessarily, retaining prior work each time a parent re-renders, even when the component's own props are referentially stable.
  • Garbage collection pauses grow over the lifetime of a long-running single-page session, with the largest retained size concentrated in a single component subtree rather than spread across the app.

Likely causes

  • Effects whose dependency arrays are missing values, causing recreated closures to be retained by the effect's internal slot, as described in React's useEffect reference documentation on dependency comparison.
  • Subscriptions, listeners, or timers registered in effects or constructors without a matching cleanup return, so the framework never receives an explicit teardown signal.
  • Stale-closure capture of large objects (caches, query clients, undo stacks, WebSocket payloads) via dependency-array identity drift, where a new object reference is produced on every render and treated as a fresh dependency.
  • Context providers whose value prop is an object or function rebuilt each render, causing consumers to retain a chain of prior provider snapshots and the work captured inside them.
  • Third-party libraries (charts, maps, editors, virtualization grids) instantiated imperatively without disposal, holding GPU buffers, ResizeObservers, or MutationObservers outside React's reconciliation reach.
  • Refs used as escape hatches to store large payloads across renders, where the ref's `.current` is never nulled on unmount and the referenced objects remain reachable from the fiber.

First ten minutes

  1. 01Record a Performance Memory baseline in DevTools: capture the JS heap size, total JS heap size, and the count of DOM nodes, then perform the user interaction that allegedly retains memory and re-capture after a forced garbage collection.
  2. 02Open the React DevTools Profiler, enable "Record why each component rendered," and walk through the suspected interaction. Note components whose render reason is "hooks changed" or whose effect dependencies array shows a new identity on every commit.
  3. 03Take a heap snapshot before the interaction and a second snapshot after navigating away and forcing a full user interaction cycle, then compare the two snapshots filtered by the suspected component name to surface retained objects.
  4. 04Audit every effect, subscription, and timer registered in the suspected subtree and confirm that each has a paired teardown; tag any pair that lacks teardown as a retention candidate rather than an automatic fix target.
  5. 05Reproduce the retention with the React StrictMode double-invocation enabled in development; effects that produce a console warning or that visibly leak under double-mount are the highest-priority candidates, since the same pattern will retain under single mount.
  6. 06Capture the exact dependency arrays of suspect effects, the identity of the values passed in, and whether any of them are functions, objects, arrays, or class instances created in the parent component's render body.

Evidence to collect

  • Two heap snapshots taken before and after a forced interaction cycle, with the suspected component name as the comparison filter and the "Retained Size" column sorted descending.
  • A Profiler trace of the same interaction, with the "Why did this render?" panel enabled, showing the dependency diff for each effect in the suspected subtree.
  • StrictMode console output capturing any double-invocation warnings, missing-cleanup warnings, or setState-on-unmounted warnings that occur during the reproduction path.
  • A static inventory of every useEffect, useLayoutEffect, useImperativeHandle, useEvent, addEventListener, setInterval, setTimeout, ResizeObserver, MutationObserver, IntersectionObserver, WebSocket, and EventSource in the component and its descendants, with a yes/no column for paired cleanup.
  • Context provider value identity log: a render counter on the provider showing how many distinct object identities have been produced during the reproduction window.

Where to look

  • The fiber boundary of the component being unmounted or remounted: in a heap snapshot's "Retainers" view, follow the chain from a retained object back to a fiber node, then to its effect list, to identify which effect slot is anchoring the retention.
  • The effect dependency arrays of the component and its ancestors: React compares dependencies by Object.is, so any non-primitive value produced inside a parent's render body will change identity on every render and force the effect to re-attach.
  • The cleanup return of every effect: confirm the teardown function calls the exact API used to register the subscription (removeEventListener with the same function reference, clearInterval with the same handle, observer.disconnect(), socket.close()).
  • Context provider boundaries upstream of the component: a provider whose value is an object literal or inline function rebuilds identity each render and forces every consumer to re-receive the prior reference until React commits.
  • Third-party library integration points: chart, map, editor, and virtualization components typically own a destroy, dispose, or unmount method that must be called from the React effect's cleanup, not from a class componentWillUnmount alone.

Diagnostic steps

  1. 01Confirm the symptom is retention, not a leak elsewhere: take three consecutive heap snapshots separated by idle periods and a forced major GC; if the post-GC heap size returns to a stable baseline, the issue is retention rather than unbounded growth.
  2. 02Bisect the suspected component tree by toggling siblings off and re-running the interaction; the smallest subtree that still reproduces the retention is the boundary to instrument.
  3. 03For each effect in the suspect subtree, compare its dependency array across two consecutive renders using a temporary useEffect that logs the previous and current dependency values; mismatches identify the unstable input.
  4. 04For each subscription registered in the suspect subtree, disable the subscription, repeat the interaction, and check whether the retained size drops; a measurable drop implicates that subscription, while no change shifts suspicion to the closure or context.
  5. 05Wrap any large value passed across an effect boundary in a stable reference (useMemo, useRef, or a module-level constant) and re-measure; if retention disappears, identity drift is the cause rather than a missing cleanup.
  6. 06For context, count the number of distinct provider value identities produced during the interaction window; if the count grows linearly with the interaction, the provider itself is the retention source.

Common mistakes

  • Assuming a missing dependency is always the fix: React's reference documentation warns that omitting a dependency suppresses the lint rule but does not change identity comparison semantics, so an unstable parent value can still cause re-attachment regardless of the array contents.
  • Calling removeEventListener, clearInterval, or observer.disconnect with a function reference that differs from the one passed to addEventListener, setInterval, or observer.observe; identity mismatch silently leaves the original handler attached.
  • Returning a cleanup that nullifies state or calls setState on an unmounted component, which causes a new warning while still retaining the original closure through the effect slot.
  • Wrapping the entire app in a single context provider that holds growing state (caches, query results, undo history) and concluding that "context is slow" rather than that the provider's value identity is unstable across renders.
  • Trusting production heap snapshots without a forced GC: a snapshot taken before GC will report reachable but collectable memory as retained, producing false positives for any short-lived allocation.
  • Replacing useEffect with useMemo or useRef as a "fix" for retention; these hooks do not register a cleanup and will not release work on unmount, so the retention simply moves to a different slot.

Safe fixes

  • Add a paired cleanup to every effect that registers a subscription, timer, observer, or imperative handle, and verify the cleanup uses the same function reference and the same API as registration; the fix is conditional on the diagnostic step that confirmed the subscription is the retention source.
  • Stabilize effect dependency values that are produced in a parent's render body by lifting them to useMemo, useRef, or module scope, so the dependency's Object.is comparison returns true across renders; the fix is conditional on a Profiler trace showing identity drift on the dependency.
  • Split a context provider into a state context and a dispatch context, and memoize the value object with useMemo, so consumers do not re-receive a new identity on every state change; the fix is conditional on a provider value identity log showing linear growth during the interaction.
  • Wrap third-party imperative instances in a small adapter component whose effect calls the library's documented destroy/dispose method in cleanup, and verify against the library's official API documentation rather than inferred behavior; the fix is conditional on the library being the confirmed retention source.
  • For refs holding large payloads, add an effect whose cleanup nulls the ref's .current on unmount; the fix is conditional on a heap snapshot showing the ref as a retainer chain leading to the payload.
  • Enable React StrictMode in development and treat any double-invocation-induced warning as a confirmed retention candidate; the fix is conditional on the warning reproducing under double-mount.

Prove the fix

  1. 01After the fix, repeat the same interaction cycle that produced the original symptom: navigate to the screen, perform the retained work, navigate away, force a major garbage collection, and take a third heap snapshot. The post-GC snapshot's retained size for the component name must be within an agreed tolerance of the pre-interaction baseline, not merely lower than the first failing measurement.
  2. 02Run the React DevTools Profiler over the same interaction under the same conditions; the suspected effect's dependency array must show stable Object.is comparisons across the entire trace, and the component's render reason must no longer list "hooks changed" for the suspected hook.
  3. 03With StrictMode double-invocation enabled, the reproduction path must complete without producing any new console warning related to missing cleanup, state on unmounted component, or non-stable dependency.
  4. 04Add an automated test that mounts the component, performs the interaction, unmounts, awaits a tick, and asserts via a test renderer or a counting subscription that the registered resources have been released to zero; the test is the regression guard, not the manual snapshot.
  5. 05Document the exact dependency array, cleanup API, and stabilization primitive used, so the next engineer can verify the fix against React's useEffect reference rather than against tribal knowledge.

Prevention and next steps

  • Adopt a project rule that every effect registering a subscription, timer, observer, or imperative handle must include a paired cleanup that uses the same function reference and API, enforced by a lint rule or a code review checklist item.
  • Lift large or identity-unstable values out of render bodies into useMemo, useRef, or module scope, so they do not enter dependency arrays or context provider values as fresh identities.
  • Keep React StrictMode enabled in development to surface double-invocation retention candidates before they reach production, and treat any StrictMode warning as a release blocker for the affected change.
  • Run a scheduled heap snapshot comparison in staging against the production-shaped interaction, and alert when the post-interaction retained size exceeds a documented threshold relative to the pre-interaction baseline.
  • Maintain a per-component retention test that mounts, exercises, unmounts, and asserts resource release, and run it in CI so a regression in cleanup is caught before merge rather than after deploy.

Safe commands and checks

Start the Chrome DevTools detached heap capture on the page under test, then from the page console call: performance.measureUserAgentSpecificMemory().then(r => console.log(r.bytes)); compare against a pre-interaction baseline.
Force a major garbage collection from the DevTools Memory panel using the trash-can icon while "Allocation instrumentation timeline" is set to "All allocations", so subsequent heap snapshots reflect only reachable, non-collectable objects.
From the browser console, list currently registered timers on the page using: const t = setTimeout(() => {}, 0); for (let i = 1; i < 100000; i++) clearTimeout(i); clearTimeout(t); — replace this with the application's own timer registry if one is exposed, because window.setTimeout handles are not directly enumerable.
Programmatically count active EventSource and WebSocket connections on the page by iterating: for (const k of Object.keys(window)) if (window[k] instanceof WebSocket) console.count(k); — confirm the count returns to the pre-interaction baseline after the suspected screen unmounts.
Run the React Profiler via react-dom/client's unstable_trace API, wrapping the suspected interaction, and export the trace file for offline comparison against a known-good golden trace.