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
setIntervalorsetTimeoutcallback 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 youclearInterval. - 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
useRefthat stored a DOM node, or a parent holding a child ref, keeps thestateNode— and therefore itsDetached HTMLElement— alive after removal. - Stale closures capturing state. An async callback (a
fetch.then, a promise resolution) capturedsetStateand 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.
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.
-
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. -
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.
-
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.
-
Capture the comparison snapshot. Take
Snapshot 2, then set the view dropdown from Summary to Comparison and chooseSnapshot 1as the base. Expect a# Deltacolumn. Sort it descending. -
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 filterDetachedto catch the orphaned DOM. ExpectDetached HTMLDivElement,Detached HTMLElement, and similar with a matching positive delta. -
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: aTimer(uncleared interval), an array inside an event target (addEventListener), a store’s subscriber list, or acontextclosure. That terminal object is your bug. The step-by-step heap snapshot comparison guide covers reading these chains in depth. -
Confirm the fix. Apply cleanup, rebuild, and repeat steps 1–5. Expect the
FiberNodeandDetacheddeltas 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.
Related
- Framework-Specific Memory Optimization — the parent guide covering React, Vue, and Angular retention patterns side by side
- Fixing useEffect Cleanup Memory Leaks — a focused child guide on the return-function teardown contract
- Detached DOM Nodes and Memory Retention — the main section on orphaned nodes that React refs frequently produce
- Closure Memory Leaks in Modern JavaScript — the closure capture mechanics behind stale effect and callback retention