Your dashboard has been open for forty minutes. The interactions that felt instant at minute two now take a visible beat to respond. You open the Chrome Task Manager, enable the JavaScript memory column, and watch the live number climb in a staircase pattern that never comes back down. Nothing in the React DevTools profiler looks wrong: render counts are flat, commit durations are stable. The problem is not that React is rendering too much. The problem is that React is holding onto DOM that no longer exists on the page.
This is the class of leak that heap snapshot diffing is built to find. It is also the class of leak that most teams never look for, because the symptom (slow interactions over time) looks like a rendering problem and the cause (retained detached nodes) lives in the memory panel. This article covers how to find those nodes, how to read the retaining tree to identify the JavaScript reference keeping them alive, and which React 19.x patterns produce them.
What a detached node actually is
Chrome’s own documentation is precise about the definition: a DOM node can only be garbage collected when there are no references to it from either the page’s DOM tree or JavaScript code. A node is “detached” when it has been removed from the DOM tree but some JavaScript still references it. Detached DOM nodes are described by Chrome as a common cause of memory leaks (Fix memory problems).
That definition matters because it separates two things people conflate:
- A detached DOM node is a real DOM element that was removed from the document but is still reachable from JavaScript. It shows up in a heap snapshot under a
Detachedclass filter, and it carries the full weight of the element plus its subtree. - A leaked JavaScript object is any object that is still reachable when it should not be. It may or may not wrap a DOM node. A stale closure that captures a large array is a leaked object with no DOM involvement at all.
Both show up in the same heap snapshot, but they are found differently. Detached nodes are found by filtering the class list for Detached. Leaked objects are found by comparing two snapshots and looking at what grew. The Chrome Memory panel exposes both: the Heap snapshot profile type shows memory distribution among JavaScript objects and related DOM nodes, and the Detached elements profile type shows objects retained by a JavaScript reference (Memory panel overview).
The wrong approach: chasing render counts
When a page gets sluggish over time, the reflex is to open React DevTools, find the component that re-renders most, and wrap it in memo. This fails at scale for a specific reason: a detached node leak does not increase render counts. It increases the cost of every render that touches the surrounding tree, because the browser’s garbage collector has more live objects to trace, and because the retained subtree keeps its event listeners registered.
Chrome’s documentation is explicit about the user-visible consequence: during garbage collection, all script execution is paused. If the browser is collecting a lot, script execution gets paused a lot (Fix memory problems). That pause is what shows up as INP regression. You will not see it in a render profile, because it is not a render.
The second wrong approach is to assume that unmounting a component frees its DOM. It does not, if anything outside React still holds a reference. React removes the node from the tree; the browser frees it only when the last JavaScript reference goes away.
The procedure: snapshot diffing with a forced GC
The workflow below assumes Chrome DevTools. The Memory panel is opened via the Command menu (Command/Ctrl + Shift + P, type memory, select Show Memory) or via More tools > Memory.
- Establish a baseline. Load the page, perform the interaction you suspect leaks (open and close a modal, navigate between routes, toggle a panel), then return to the starting state. Take a heap snapshot. Before taking it, click the collect garbage (mop) button so the snapshot reflects reachable memory, not pending garbage.
- Repeat the interaction. Run the same open/close or navigate/return cycle several more times. Ten cycles is a reasonable starting point; the goal is to make the leak large enough to see above noise.
- Force GC and take a second snapshot. Same collect-garbage step. If the interaction is clean, the second snapshot should be roughly the same size as the first.
- Switch the view to Comparison. In the snapshot list, select the second snapshot and choose Comparison from the dropdown above the class list. The
# Deltacolumn now shows what grew between the two snapshots. - Filter for
Detached. TypeDetachedinto the Class filter. Any detached node that appears with a positive delta is a node that was created during your interaction cycles and never released. - Open the retaining tree. Select a detached node. The lower panel shows the retaining tree: the chain of references from a GC root down to this node. Read it bottom-up. The first non-internal frame is your suspect.
The retaining tree is the part people skip, and it is the only part that tells you what to fix. A detached node retained by a Window scope means a global or module-level variable. A detached node retained by a closure means a function that captured it. A detached node retained by a subscription callback means an unsubscribe that never ran.
Pattern 1: state that outlives the component
The most common retaining path in a React 19.x app is a state setter captured in an async callback that resolves after unmount. The classic shape:
useEffect(() => {
fetchBio(person).then(result => {
setBio(result); // runs even if the component is gone
});
}, [person]);
React’s own documentation addresses this directly. The recommended pattern uses an ignore flag initialized to false and set to true during cleanup, so the resolved promise does not call setBio after the effect has been torn down (useEffect reference). The documentation frames this as protection against race conditions, where network responses arrive in a different order than they were sent. It also prevents the post-unmount state update.
Why this matters for memory: a setState call on an unmounted component does not throw in React 19, and it does not necessarily retain the DOM by itself. The retention happens when the resolved value is a large object graph, or when the callback closes over a ref that points at a DOM node. The ignore flag stops the write, which lets the closure and everything it captured become collectable.
For fetch specifically, AbortController is the stronger tool. The AbortController interface represents a controller object that allows you to abort one or more Web requests as and when desired, and abort() is able to abort fetch requests, consumption of any response bodies, and streams (MDN: AbortController). Aborting the request releases the response body and any listeners attached to the signal, rather than waiting for the promise to settle and then discarding the result.
useEffect(() => {
const controller = new AbortController();
fetch(`/api/bio/${person}`, { signal: controller.signal })
.then(r => r.json())
.then(setBio)
.catch(err => { if (err.name !== 'AbortError') throw err; });
return () => controller.abort();
}, [person]);
The ignore flag and AbortController are not interchangeable. The flag prevents a state write; the controller cancels the network work and frees the response. Use both when the response is large.
Pattern 2: subscriptions that never unsubscribe
React’s useEffect documentation lists the external systems that require cleanup explicitly: a timer managed with setInterval() and clearInterval(), an event subscription using window.addEventListener() and window.removeEventListener(), and a third-party animation library with an API like animation.start() and animation.reset(). The rule stated in the docs is that the cleanup function should stop or undo whatever the setup function was doing, and that the user should not be able to distinguish between setup running once (production) and setup → cleanup → setup (development) (useEffect reference).
When a subscription is not torn down, the callback stays registered on the external system. If that callback closes over a DOM node (a ref, a container element, a chart canvas), the node is retained. In the heap snapshot, the retaining tree will show the path through the subscription’s callback list, not through React.
The failure mode is subtle in development because React 19 runs an extra setup+cleanup cycle under Strict Mode. If your cleanup is incomplete, the extra cycle may mask or expose the problem depending on what the external system does with duplicate subscriptions. The documentation is direct about this: the extra cycle is a stress test that ensures your cleanup logic mirrors your setup logic, and if it causes a problem, implement the cleanup function.
Pattern 3: useSyncExternalStore with a store that holds DOM
useSyncExternalStore is the correct hook for reading from an external store in a way that is safe for concurrent rendering and server rendering. Its contract is specific: the subscribe function takes a single callback and returns a function that cleans up the subscription, and the getSnapshot function returns a snapshot of the data the component needs (useSyncExternalStore reference).
Two documented behaviors create retention risk in large apps:
Resubscription on every render. The React docs state that if a different subscribe function is passed during a re-render, React will re-subscribe to the store using the newly passed function. The troubleshooting section calls out the exact anti-pattern: a subscribe defined inside the component body is a different function on every render, so React resubscribes every time. The fix is to declare subscribe outside the component, or wrap it in useCallback so it only changes when its arguments change. If your store’s subscribe implementation adds a listener and the unsubscribe is not called before the next subscribe, you accumulate listeners, and each listener may hold a reference to the component’s DOM.
Uncached snapshots. The docs warn that getSnapshot must return the same value while the store has not changed, and that returning a new object every call produces the error “The result of getSnapshot should be cached.” Beyond the infinite-loop symptom, an uncached snapshot means React re-renders on every store notification, which increases the window during which closures and refs are live.
For server-rendered apps, the third argument getServerSnapshot is required if the component renders on the server. The docs note that the server snapshot must be the same between client and server, and is usually serialized and passed from the server to the client. A store that holds DOM references cannot be serialized, which is a signal that the store is doing too much: keep DOM references in refs and keep the external store to serializable data.
Pattern 4: stale closures in long-lived callbacks
A stale closure is a function that captured a value from an earlier render and is still being invoked. It retains everything in its scope, including any DOM node it touched. The retaining tree in a heap snapshot will show the closure as the retaining edge, and the closure’s scope will show the captured variables.
The React docs describe the mechanism that produces stale closures in effects: if some dependencies are objects or functions defined inside the component, there is a risk they cause the effect to re-run more often than needed, and the fix is to remove unnecessary object and function dependencies or extract non-reactive logic outside the effect. The inverse problem, a dependency list that is too short, produces a closure that captures a value from a render that no longer exists.
In a heap snapshot, a stale closure retaining a detached node looks like this: the detached node’s retaining tree passes through a function object, and that function’s scope contains a variable whose value is the node. The fix is not to add the missing dependency blindly (that can cause the effect to re-run on every render and create a different leak). The fix is to restructure so the callback does not need to capture the node: pass the node as an argument, read it from a ref at call time, or move the callback outside the component.
Server-side: catching heap growth across renders
Detached node leaks are a client problem, but the same class of retention shows up on the server as heap growth across repeated renders of the same component tree. Node exposes process.memoryUsage() for this. The method reports memory usage in bytes, and the Node documentation includes a note on interpreting the values (Node.js process documentation).
A practical check for a server-rendered React 19.x app: render the same route N times in a loop, call process.memoryUsage().heapUsed after each render, and force a GC between samples if you run with --expose-gc. A flat line after GC means the render is clean. A line that climbs and does not return to baseline means something in the render path is retaining. Common causes are module-level caches keyed by request, memoization maps that never evict, and closures stored on a long-lived object.
This is a coarse signal. It tells you a leak exists, not where. For the location, use the Node inspector’s heap profiler with the same snapshot-diffing approach described above.
Reading the retaining tree without guessing
The retaining tree is a path, not a list. Read it from the detached node upward. The frames you will see most often, and what they mean:
Window/global— a module-level variable or a property set onwindow. Look for caches, singletons, and anything assigned outside a component.Closure— a function that captured the node. Expand the closure to see which variable holds it.EventListener/EventTarget— a listener that was added and never removed. The listener’s callback is the retaining edge.Promise/then— an in-flight or resolved promise whose continuation captured the node. This is the fetch-without-abort pattern.Map/Set— a collection that was never cleared. Look for subscription registries and memo caches.
If the retaining tree passes through React internals (fiber nodes, hook state), the reference is coming from React state or a ref that was not cleared. That is a different fix: clear the ref in cleanup, or move the value out of state.
What to measure before and after
A leak fix without a measurement is a guess. The measurements that matter for this class of bug:
- Detached node count in the Comparison view after N interaction cycles. Before: grows linearly with cycles. After: flat.
- JS heap live size in the Chrome Task Manager after forced GC. Before: staircase. After: returns to baseline.
- INP on the interaction that triggers the leak, measured after the page has been open long enough for GC pressure to build. This is the user-visible number, and it is the one that justifies the work.
- Long tasks in the Performance panel during the interaction. GC pauses show up here as tasks with no script attribution.
Take all four before the fix and after. If the detached node count drops but INP does not move, the leak was not on the critical path and the fix is still correct but not urgent. If INP moves and the node count does not, you fixed something else.
FAQ
Does React 19 automatically clean up effects on unmount?
React runs the cleanup function returned from useEffect after the component is removed from the DOM. It does not clean up anything you did not return a cleanup for. Timers, subscriptions, and in-flight requests need explicit teardown.
Why does the leak only show up in production?
Development builds run extra setup+cleanup cycles under Strict Mode, which can surface or mask incomplete cleanup. Production builds also run longer between reloads, which is when accumulation becomes visible. The leak exists in both; the timeline differs.
Is a detached node always a leak?
No. A node can be detached and still referenced intentionally, for example a cached offscreen element. It is a leak when the reference outlives its purpose. The heap snapshot shows the reference; only you can judge whether it should exist.
Can I find these leaks without a heap snapshot?
The Detached elements profile in the Memory panel shows detached elements retained by JavaScript reference and reports the exact HTML nodes and node count. It is faster than a full snapshot diff for confirming the problem exists, but the retaining tree in a heap snapshot is what identifies the cause.
Does useSyncExternalStore leak if I forget to unsubscribe?
React calls the unsubscribe function returned by subscribe when the component unmounts or when the subscribe function identity changes. If your store’s subscribe adds a listener and your unsubscribe does not remove it, the listener accumulates. The React docs specifically warn about passing a new subscribe function on every render, which causes resubscription on every render.