Inside a Real React Memory Leak: Debugging Detached DOM Trees and Stale Closures
Modern React applications can run for hours inside a browser tab.
Inside a Real React Memory Leak: Debugging Detached DOM Trees and Stale Closures
Modern React applications can run for hours inside a browser tab.
That changes everything. non members can read **here**

Unlike traditional multi-page apps, long-lived SPAs accumulate:
- event listeners
- cached data
- detached DOM nodes
- stale closures
- unbounded client-side state
And eventually:
- memory spikes
- FPS drops
- UI freezes
- browser crashes
We recently debugged a production React application where Chrome memory usage grew from 180MB to nearly 2GB after ~45 minutes of active usage.
The scary part?
Everything looked “fine” initially.
APIs were healthy. CPU usage was normal. No obvious React warnings.
But users kept reporting:
“The app becomes unusable after some time.”
This article walks through:
- how we investigated the leak
- how detached DOM trees were retaining memory
- how stale closures silently kept components alive
- how we used Chrome heap snapshots to trace the problem
- and the exact fixes that reduced memory usage by ~88%
The Symptoms
The application was a large dashboard-style React SPA with:
- real-time WebSocket updates
- infinite scrolling tables
- filters
- charts
- notifications
- React Query caching
After prolonged usage:

Users on lower-memory laptops suffered the most.
Step 1: Confirming the Leak
The first thing we checked was Chrome DevTools memory profiling.
Open:
Chrome DevTools → Memory → Heap Snapshot
We recorded snapshots:
- Initial app load
- After 15 minutes
- After 45 minutes
The heap kept growing even after:
- route changes
- modal closures
- component unmounts
That usually means:
- references are still retained somewhere
- garbage collection cannot reclaim memory
And that’s the key insight many developers miss.
A memory leak is rarely:
“too much data”
It’s usually:
“objects that are still reachable.”
Understanding Retained Memory
Chrome showed large amounts of:
- Detached HTMLDivElement
- EventListener
- Closure
- Array
This was the retaining path:
Window
└── WebSocket listener
└── Closure
└── React state
└── Detached DOM nodes
This was our first major clue.
Leak #1: Stale WebSocket Closures
Here was the original code:
useEffect(() => {
socket.on("message", (data) => {
setMessages((prev) => [...prev, data]);
});
}, []);
Looks innocent.
But there’s a serious problem.
Every mount created a new event listener. None were removed.
Over time:
- old listeners survived
- closures retained component state
- React fibers remained referenced
- memory kept growing
Even after navigating away from the page.
The Fix
We introduced proper cleanup:
useEffect(() => {
const handleMessage = (data: Message) => {
setMessages((prev) => [...prev, data]);
};
socket.on("message", handleMessage);
return () => {
socket.off("message", handleMessage);
};
}, []);
Now when the component unmounted:
- listeners were removed
- closures became unreachable
- garbage collection reclaimed memory
Heap growth immediately slowed down.
Why Closures Cause Hidden Leaks
A closure retains references to variables from its lexical scope.
That means this:
const largeData = new Array(100000).fill("memory");
socket.on("message", () => {
console.log(largeData.length);
});
Keeps largeData alive as long as the listener exists.
Even if the component disappears from the UI.
This becomes dangerous in:
- WebSockets
- intervals
- subscriptions
- observers
- global event handlers
Leak #2: Detached DOM Trees
This was the nastiest issue.
Chrome Heap Snapshot showed thousands of:
Detached HTMLDivElement
A detached DOM node means:
- the node is removed from the document
- but JavaScript still holds a reference
So garbage collection cannot clean it up.
The Problematic Code
We found this pattern:
const cachedElements: HTMLElement[] = [];
function storeElement(el: HTMLElement) {
cachedElements.push(el);
}
And later:
<div ref={(el) => el && storeElement(el)} />
Every rendered element got stored globally.
When components unmounted:
- DOM disappeared visually
- references still existed
- memory stayed allocated
This is a classic detached DOM leak.
Why Detached DOM Nodes Are Dangerous
Detached nodes are especially expensive because they often retain:
- child nodes
- event listeners
- styles
- layout metadata
One retained parent can accidentally keep an entire subtree alive.
We found retained trees with:
- charts
- SVG nodes
- virtualized rows
- tooltip containers
Some subtrees contained thousands of nodes.
The Fix
We removed unnecessary DOM caching entirely.
When references were actually needed, we used weak references and cleanup logic.
Before:
cachedElements.push(el);
After:
const elementRef = useRef<HTMLElement | null>(null);
And cleanup:
useEffect(() => {
return () => {
elementRef.current = null;
};
}, []);
Heap snapshots improved dramatically after this change.
Leak #3: Infinite React Query Cache Growth
The app heavily used React Query for real-time data.
Original configuration:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
},
},
});
This effectively disabled cache eviction.
Combined with dynamic query keys:
["messages", roomId, timestamp]
The cache grew endlessly.
The Fix
We introduced proper garbage collection:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
gcTime: 5 * 60 * 1000,
},
},
});
We also normalized query keys:
["messages", roomId]
Instead of timestamp-based keys.
This reduced retained query memory massively.
Leak #4: Infinite Scroll DOM Explosion
Another major issue: our infinite scroll rendered thousands of rows simultaneously.
The DOM node count reached nearly 50k.
Even without leaks, this destroys performance.
Original rendering:
items.map((item) => <Row key={item.id} item={item} />);
The Fix: Virtualization
We switched to react-window.
import { FixedSizeList as List } from "react-window";
<List
height={600}
itemCount={items.length}
itemSize={50}
width={"100%"}
>
{Row}
</List>
Now only visible rows rendered.
DOM nodes dropped from:
48,000 → 4,200
Scrolling became smooth again.
Using Chrome Heap Snapshots Properly
Heap snapshots became the most valuable debugging tool.
Things we specifically looked for:
1. Detached Nodes
Search:
Detached
This quickly reveals leaked DOM trees.
2. Retained Size
Two important concepts:
MetricMeaningShallow SizeMemory used by the object itselfRetained SizeTotal memory kept alive because of the object
Retained size matters more during leak analysis.
3. Retaining Paths
This is where leaks become understandable.
Example:
Window
→ listener
→ closure
→ component state
→ detached node
Retaining paths tell you:
WHY the object still exists.
Not just WHAT exists.
React StrictMode Confused the Investigation
React StrictMode intentionally double-invokes effects in development.
That caused:
- duplicate subscriptions
- duplicated logs
- misleading memory behavior
Some leaks appeared “worse” in dev than production.
Understanding StrictMode behavior was critical during debugging.
Final Benchmark Results
After all fixes:

The app became dramatically more stable.
Key Engineering Lessons
1. Most Memory Leaks Are Retaining Problems
Garbage collection works well.
The real issue is:
something still references the object.
2. Long-Lived SPAs Behave Differently
Memory issues often appear:
- after 30+ minutes
- after navigation cycles
- during prolonged sessions
Short local testing rarely catches them.
3. Detached DOM Trees Are Extremely Expensive
One retained parent node can accidentally preserve thousands of descendants.
4. Closures Can Silently Retain Huge Objects
Especially with:
- sockets
- intervals
- async callbacks
- global listeners
5. Heap Snapshots Are Underrated
Most frontend developers never properly learn:
- heap analysis
- retained size
- retaining paths
- allocation timelines
But these tools are essential for serious production debugging.
Final Thoughts
Frontend performance engineering is no longer just:
- bundle optimization
- lazy loading
- Lighthouse scores
Modern React applications behave more like long-running desktop applications.
That means:
- memory management matters
- lifecycle management matters
- observability matters
- browser internals matter
And sometimes the biggest production bottlenecks are not on the backend at all.
They’re hidden inside the browser tab sitting open for the last two hours.
If you enjoyed this article, connect with me on LinkedIn where I share deep dives on:
- React internals
- frontend architecture
- AI engineering
- browser performance
- scalable systems
And follow my work here for more production engineering breakdowns.
메타데이터
- post_id
- 81d5dce0c7ac
- slug
- inside-a-real-react-memory-leak-debugging-detached-dom-trees-and-stale-closures-81d5dce0c7ac
- url
- https://medium.com/front-end-world/inside-a-real-react-memory-leak-debugging-detached-dom-trees-and-stale-closures-81d5dce0c7ac
- canonical_url
- https://medium.com/front-end-world/inside-a-real-react-memory-leak-debugging-detached-dom-trees-and-stale-closures-81d5dce0c7ac
- author_url
- https://medium.com/@sachinkasana
- status
- ok
- fetched_at
- 2026-06-12 22:02:08