React Component Memory Leaks & Lifecycle Cleanup

React components leak memory when an unmounted component’s fiber, DOM subtree, or captured state cannot be reclaimed because a timer, subscription, event listener, or stale closure still references it. As part of Framework-Specific Memory Optimization, this guide isolates the exact retention channels unique to React’s fiber architecture and gives you a deterministic Chrome DevTools workflow to prove and eliminate each one. The mechanics overlap heavily with generic detached DOM nodes and closure memory leaks, but React adds its own twist: the reconciler keeps a parallel fiber tree that becomes its own class of orphaned object. For the single most common cause — a useEffect that starts work but never tears it down — see the focused walkthrough on fixing useEffect cleanup leaks.

Conceptual Grounding: Fibers, Unmount, and Retention

React maintains two trees. The element tree is the immutable output of your render functions; the fiber tree is the mutable, long-lived bookkeeping structure the reconciler walks to schedule work. Each FiberNode carries a stateNode (the real DOM element or class instance), a memoizedState linked list holding every hook — including each useEffect closure and its dependency array — and return, child, and sibling pointers wiring the tree together.

When a component unmounts, React’s commit phase runs the delete path: it invokes each effect’s cleanup function, detaches the stateNode from the DOM, and drops its own pointer to the fiber. At that point the fiber and its DOM subtree should become unreachable from any GC root and get swept by V8’s mark-and-sweep collector. The leak happens when something outside React’s tree still points inward.

The retention channels specific to React are:

  • Uncleared timers. A setInterval or setTimeout callback is a closure over the fiber’s hook state. The browser’s timer registry is a GC root, so the callback — and everything it captured — survives unmount until you clearInterval.
  • Surviving subscriptions. An event emitter, WebSocket, IntersectionObserver, or store subscription holds your callback in its listener list. That list outlives the component.
  • Detached fibers via refs. A useRef that stored a DOM node, or a parent holding a child ref, keeps the stateNode — and therefore its Detached HTMLElement — alive after removal.
  • Stale closures capturing state. An async callback (a fetch .then, a promise resolution) captured setState and the surrounding fiber; when it resolves post-unmount, it both warns and retains.

Because these retainers are GC roots or reachable from them, the fiber is promoted out of V8’s young generation and lingers in old space, so the heap climbs monotonically across route changes. The diagram below traces one such path from the timer registry root through a captured closure into the detached fiber and its DOM node.

React fiber retention: an uncleared timer holding a detached fiber and DOM subtree On the left, a browser Timer Registry acting as a GC root holds a setInterval callback closure. The closure captured the component's hook state, so it points into the unmounted FiberNode in React's fiber tree. The FiberNode's stateNode field points to a Detached HTMLElement in the DOM heap. Because a path runs from the GC root through the closure to the fiber and the detached node, V8 cannot collect any of them. A dashed line shows where clearInterval in the effect cleanup would sever the path and free the whole chain. Browser roots React fiber tree (V8 heap) DOM heap Timer Registry GC root (setInterval) tick() closure captured hook state FiberNode unmounted, memoizedState + stateNode ptr child FiberNode sibling / return links Detached HTMLElement no parentNode clearInterval cleanup severs the whole chain

Diagnostic Workflow

Follow these steps to convert a vague “the app gets slow after a while” report into a named retainer you can fix. Every step lists the exact DevTools path and the output you should expect.

  1. Establish a clean baseline. Load the app, navigate away from the suspect route, and open DevTools → Memory → Heap Snapshot → Take snapshot. Expect a snapshot labelled Snapshot 1. Note the total size in MB shown at the top of the profiles list.

  2. Amplify the leak. Navigate into the leaking component’s route and back out 5–10 times. Repetition turns a 40 KB per-instance leak into a visible 400 KB delta that survives GC noise. This mirrors the SPA route-churn method in profiling single-page apps in DevTools.

  3. Force a major GC. Click the trash-can Collect garbage icon in the Memory panel toolbar. This runs a full mark-and-sweep so anything genuinely unreachable is gone before you measure. Skipping this makes scheduling lag look like a leak.

  4. Capture the comparison snapshot. Take Snapshot 2, then set the view dropdown from Summary to Comparison and choose Snapshot 1 as the base. Expect a # Delta column. Sort it descending.

  5. Filter for the React signatures. In the class filter box type FiberNode. A healthy app returns to a stable count; a leak shows a positive delta roughly equal to your repeat count. Then filter Detached to catch the orphaned DOM. Expect Detached HTMLDivElement, Detached HTMLElement, and similar with a matching positive delta.

  6. Read the retainer chain. Select a surviving FiberNode, open the Retainers pane at the bottom, and expand the shortest path to a GC root. Expect to land on one of: a Timer (uncleared interval), an array inside an event target (addEventListener), a store’s subscriber list, or a context closure. That terminal object is your bug. The step-by-step heap snapshot comparison guide covers reading these chains in depth.

  7. Confirm the fix. Apply cleanup, rebuild, and repeat steps 1–5. Expect the FiberNode and Detached deltas to return to zero (±1 for the currently mounted instance).

Code Patterns & Signatures

The leaks below are ordered by how often they appear in real audits. Each block is runnable and commented line by line.

An interval that outlives the component is the textbook React timer leak; return a cleanup that clears it so the callback closure — and the fiber it captured — becomes collectable.

import { useEffect, useState } from "react";

function Clock() {
  const [now, setNow] = useState(Date.now()); // hook state
  useEffect(() => {
    // start a repeating timer on mount
    const id = setInterval(() => {
      setNow(Date.now());   // closure captures setNow + fiber
    }, 1000);               // fires every 1000 ms
    // cleanup runs on unmount (and before re-run)
    return () => clearInterval(id); // severs the GC root path
  }, []);                   // empty deps = mount/unmount only
  return <time>{now}</time>;
}

Event listeners and observers registered against a long-lived target retain your callback until explicitly removed; pair every addEventListener with a removeEventListener in cleanup.

import { useEffect, useRef } from "react";

function useResizeLog() {
  const count = useRef(0);          // survives re-renders
  useEffect(() => {
    // named handler so remove targets the same fn
    const onResize = () => {
      count.current += 1;           // captured ref, not state
    };
    window.addEventListener("resize", onResize); // GC root
    // remove the exact same reference on unmount
    return () => {
      window.removeEventListener("resize", onResize);
    };
  }, []);                           // register once per mount
}

Async work that resolves after unmount both triggers the state-update warning and retains the fiber; abort it with an AbortController so the resolved path drops its reference to setState.

import { useEffect, useState } from "react";

function Profile({ userId }) {
  const [user, setUser] = useState(null);
  useEffect(() => {
    const ctrl = new AbortController(); // cancellation token
    fetch(`/api/users/${userId}`, {
      signal: ctrl.signal,             // fetch aborts on signal
    })
      .then((r) => r.json())      // parse if not aborted
      .then((data) => setUser(data)) // safe post-abort
      .catch((e) => {
        if (e.name !== "AbortError") throw e; // ignore
      });
    // abort in-flight request on unmount
    return () => ctrl.abort();    // drops stale closure
  }, [userId]);                   // re-fetch on change
  return user ? <span>{user.name}</span> : null;
}

A ref that caches a DOM node must be nulled on cleanup, otherwise the useRef box keeps the stateNode alive as a Detached HTMLElement after removal.

import { useEffect, useRef } from "react";

function Chart() {
  const elRef = useRef(null);         // holds the canvas node
  useEffect(() => {
    const el = elRef.current;         // capture current node
    const chart = drawChart(el);      // 3rd-party keeps node refs
    return () => {
      chart.destroy();                // release library internals
      elRef.current = null;           // drop our own node handle
    };
  }, []);
  return <canvas ref={elRef} />;      // node assigned to ref
}

Symptom-to-Fix Reference

Symptom Root Cause Immediate Action Measurable Impact
FiberNode count climbs per route change Uncleared setInterval in effect Return clearInterval from effect Delta drops to 0 in Comparison
Detached HTMLElement grows Ref caches removed DOM node Null the ref in cleanup Freed ~40 KB per instance
“setState on unmounted” warning Async closure resolves post-unmount Abort fetch via AbortController Warning gone, fiber collected
Heap climbs ~1 MB per minute addEventListener never removed Add removeEventListener cleanup Flat heap over 10 min
Doubled timers in dev only Missing cleanup, Strict Mode remount Add return cleanup to effect Timer count halves
Store subscribers list grows Unsubscribed store listener Call unsubscribe in cleanup Retained size stops rising
Old-space size never falls Detached fiber promoted, retained Force GC, fix terminal retainer Major GC reclaims subtree

Edge Cases & Gotchas

Strict Mode double-invoke hides a missing cleanup as a “React quirk.” In development, React 18 Strict Mode mounts, unmounts, then remounts each component to surface effects without cleanup. If you see two intervals or duplicate listeners, that is not a Strict Mode bug — it is proof your effect never released the first resource. Fix: add the returned cleanup so the count stays at one across the deliberate remount.

Stale closures capturing an old state value, not just a reference. An effect with [] deps that reads state captures the first render’s value forever. Beyond correctness bugs, the captured render scope keeps that fiber generation alive. Fix: list the real dependencies, or use the functional updater setState(prev => ...) so the closure captures nothing stale.

useRef is not garbage collected while the component lives. A ref holding a large buffer, a WebGL context, or a DOM node persists for the component’s whole lifetime and beyond if the parent retains it. Fix: explicitly assign ref.current = null in cleanup for anything heavy; refs are mutable boxes, not managed state.

Custom hooks hide the leak one layer down. A useSubscription or useEventListener hook that forgets its own cleanup leaks in every component that uses it, and the heap snapshot points at the hook’s fiber, not your component. Fix: audit shared hooks first — one missing return () => ... there multiplies across the app.

Context providers retaining consumers. A value object recreated every render in a high-level provider forces all consumers to re-render and can keep stale consumer closures reachable through the context’s dependency graph. Fix: memoise the provider value with useMemo so unchanged consumers detach cleanly.

Frequently Asked Questions

Does React automatically clean up useEffect side effects on unmount?

React only runs the function you return from useEffect. If your effect starts a timer, subscription, or event listener but returns nothing, React has no cleanup to call and the side effect survives unmount. React frees its own fiber bookkeeping, but any external resource you created — a setInterval, an addEventListener, a WebSocket — is yours to release in the returned cleanup function. Treat the return value as mandatory whenever the effect touches anything outside React.

Why do I see FiberNode objects growing in the heap snapshot?

A growing FiberNode count after repeated mount/unmount means React’s reconciler cannot release the fiber for an unmounted tree because something outside React still references it. The usual retainers are a setInterval callback, an unremoved addEventListener, or a stale closure captured by a long-lived subscription, all of which transitively hold the fiber’s stateNode. Open the Retainers pane on a surviving fiber to see which one, then clear it in the matching effect cleanup.

How do I stop a “state update on an unmounted component” warning?

That warning (removed in React 18 but still a real leak signal) means an async callback resolved after unmount and called a setter whose closure retains the fiber. Abort the async work in your effect cleanup with an AbortController, or guard the setter behind a mounted ref, so the resolved promise no longer touches unmounted state. The abort approach is stronger because it also cancels the in-flight network work rather than merely ignoring its result.

Does React Strict Mode cause or reveal memory leaks?

Strict Mode does not cause leaks; it reveals them. In development it deliberately mounts, unmounts, then remounts each component so a missing cleanup function surfaces as a doubled timer or duplicate listener immediately. If a resource count grows under Strict Mode, your cleanup path is incomplete. Because Strict Mode is development-only, never rely on it as a fix — treat its doubled output as a failing test for your effect teardown.