Why Unmounted React Components Stay in Heap Snapshots
You unmount a React view, take a heap snapshot, and still find FiberNode objects, your component’s props and a detached DOM subtree whose elements carry __reactFiber$… keys. This guide from React Component Memory Leaks and Lifecycle Cleanup, in Framework-Specific Memory Optimization, explains what a fiber retains, which retainers are genuine leaks and which are measurement noise, and how to follow the path to the reference your code must release.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
FiberNode count grows with each mount/unmount |
Something outside React references a fiber, instance or DOM node | Follow retainers from a detached element to the first non-React owner | Identifies the leaking subscription, listener or cache |
Detached HTMLDivElement with __reactFiber$ / __reactProps$ keys |
DOM node retained; node points back to its fiber, which points to props and state | Treat the DOM retainer as the leak; the fiber is collateral | Whole component subtree released |
| Leak only while React DevTools is open | The extension keeps references to fibers it has displayed | Re-measure with the extension disabled | Separates tooling noise from real leaks |
| Much more retained in development than production | Dev builds keep extra debugging structures | Confirm with a production build | Accurate magnitude |
Retainer path goes through memoizedState → queue → dispatch |
A setState/dispatch function captured by an external subscription | Unsubscribe in the effect cleanup | Component state released |
Root Cause: A Fiber Is the Hub of a Component’s Memory
Every rendered React element corresponds to a fiber: an object that stores the component type, its props (memoizedProps), its hook state (memoizedState, a linked list of hooks including state queues and effect records), a pointer to the host DOM node (stateNode) for host components, and pointers to its parent, child and sibling fibers. React also keeps an alternate fiber for double buffering between renders. Host DOM nodes point back to their fiber through expando properties such as __reactFiber$<random> and __reactProps$<random>.
When a subtree unmounts, React removes its DOM nodes from the document, runs effect cleanups, and detaches the fibers from the tree — current versions clear many of the pointers on unmounted fibers precisely to limit how much a stray reference can retain. After that, the subtree should be unreachable. If a heap snapshot still contains it, something outside React is holding one of the pieces, and because the pieces point at each other — DOM node → fiber → props, state, hook closures, sibling DOM — holding any one of them can keep a large part of the subtree alive.
The usual outside holders are the same ones behind any detached DOM node: an event listener added to window or document in an effect without cleanup, a subscription to a store or socket that captured setState or a callback closing over props, a timer, an observer, a third-party widget that received a DOM node through a ref, or a module-level cache keyed by component data. The retainer path shows which one: a path through memoizedState → queue → dispatch means a captured state setter; through (closure) → context means a callback captured props or state; through Detached HTMLDivElement → __reactFiber$ means a DOM reference.
Two sources of noise mimic leaks. The React DevTools extension keeps references to fibers it has inspected or rendered in its tree, so profiling with it enabled shows retention that does not exist for users. Development builds retain more — component stacks, owner information, double-invoked effects in Strict Mode — so a small real leak looks larger. Always confirm with a production build in a clean profile, as described in profiling memory without extensions skewing results.
Step-by-Step Fix
- Reproduce with a production build in a clean profile. Build for production (with source maps), open it in a Chrome profile without extensions, and mount/unmount the suspect view ten times. Verification: the growth still appears without React DevTools and dev-mode overhead.
- Snapshot and find detached nodes or fibers. In DevTools → Memory, take a snapshot, filter by
Detachedand byFiberNode. Verification: counts grow in multiples of your mount count. - Start from a detached DOM root. Select the root element of one detached subtree (it is often easier to read than a fiber) and open its Retainers. Verification: you see the chain of retainers towards a root.
- Skip React-internal edges. Paths through
__reactFiber$…,return,child,sibling,alternateandstateNodeare React’s own structure; keep following until you reach something React does not own — a listener array, a closure in your code, a module variable, a third-party object. Verification: you reach a named owner in your or a library’s code. - Release that reference in cleanup. Return a cleanup from the effect that created the subscription, listener, timer or observer; clear refs passed to third-party widgets; evict component-keyed cache entries on unmount. Verification: the cleanup runs on unmount (a
console.countin development confirms it). - Re-run the ten mounts. Repeat steps 1–2. Verification: detached nodes and fiber counts return to baseline after unmount.
Command and Code Reference
Use case: the leak in the diagram and its fix. An external store subscription that captured setState must be removed on unmount.
// Leaky: subscribes on mount, never unsubscribes
function PriceTicker({ symbol }) {
const [price, setPrice] = useState(null);
useEffect(() => {
store.subscribe(symbol, (p) => setPrice(p)); // closure keeps the fiber reachable
}, [symbol]);
return <span>{price}</span>;
}
// Fixed: return the unsubscribe function as cleanup
function PriceTickerFixed({ symbol }) {
const [price, setPrice] = useState(null);
useEffect(() => {
const unsubscribe = store.subscribe(symbol, (p) => setPrice(p));
return unsubscribe; // runs on unmount and before re-subscribing
}, [symbol]);
return <span>{price}</span>;
}
Use case: subscribe to external stores with the React-provided hook. useSyncExternalStore manages subscription and cleanup for you.
function PriceTickerSync({ symbol }) {
const price = useSyncExternalStore(
(onChange) => store.subscribe(symbol, onChange), // must return an unsubscribe function
() => store.getPrice(symbol),
);
return <span>{price}</span>;
}
Use case: count mounted instances in development to confirm cleanup.
// dev-only helper: logs live instance counts per component name
const live = new Map();
export function useLiveCount(name) {
useEffect(() => {
live.set(name, (live.get(name) || 0) + 1);
return () => live.set(name, live.get(name) - 1); // must return to baseline after unmount
}, [name]);
}
Verification and Regression Prevention
A React unmount leak is fixed when repeated mount/unmount cycles in a production build leave no growing Detached elements and no growing FiberNode count, and when the owner you identified no longer appears in any retainer path. Confirm the fix in the same clean-profile setup you used to diagnose it, so the before/after comparison is fair.
Automate it: a Memlab scenario that navigates to the view and back, as in finding leaks with Memlab scenarios, detects unmounted fibers and detached DOM and prints retainer traces; run it in CI for views with subscriptions, sockets or third-party widgets. In code review, every effect that subscribes, listens, schedules or observes must return a cleanup — the patterns in fixing useEffect cleanup memory leaks cover the common cases.
Edge Cases and Gotchas
Refs passed to non-React code
Passing ref.current to a charting or map library hands it a DOM node that React does not track. If the library keeps it (for resize handling, for instance), the node and its fiber stay alive. Destroy the library instance in cleanup.
Context values and module caches
Values provided through context are fine, but caching them in module scope keyed by component (for example a map from ID to the component’s callbacks) outlives the component. Evict on unmount.
Offscreen and preserved state
Features that intentionally preserve hidden subtrees (tab panes kept mounted, activity/offscreen modes, keep-alive patterns in routers) keep fibers alive by design. They are not leaks, but they count toward memory; bound how many are preserved.
Error boundaries and suspended trees
A subtree that suspended or errored may be retained until the boundary resolves or resets. Long-lived suspended trees — for example waiting on a request that never settles — hold their fibers, which ties this to unsettled promises that leak their closures.
Frequently Asked Questions
Why are FiberNode objects still in my heap snapshot after unmount?
Because something outside React still references a fiber, a DOM node belonging to it, or a closure that captured its state or props. React detaches unmounted fibers from its tree, but it cannot free objects that your code or a library still points to. Follow the retainers to the first non-React owner.
Does React DevTools cause memory leaks?
It retains references to fibers it has displayed, which makes memory look higher while it is open. That is not a leak for your users, but it distorts measurements. Profile memory with the extension disabled.
Is memory higher in development mode?
Yes. Development builds keep additional debugging information and, in Strict Mode, run effects twice. Use a production build with source maps to judge real retention, and development builds only for readable names during investigation.
What does __reactFiber$ on a DOM node mean?
It is an internal property React sets on host DOM nodes pointing to their fiber, so events can be routed. On a detached node in a snapshot it explains why retaining the node also retains the fiber and, through it, props and state.
Related
- React Component Memory Leaks and Lifecycle Cleanup — the parent topic
- Debugging Detached DOM Nodes in React Components — the DOM side of the same investigation
- React Strict Mode Double Effects and Leak Detection — using Strict Mode to surface missing cleanups
- Framework-Specific Memory Optimization — the section overview