← Back to list

Inside a Real React Memory Leak: Debugging Detached DOM Trees and Stale Closures

Modern React applications can run for hours inside a browser tab.

Sachin Kasana in Front-end World · 2026-05-15 11:11 · 15 claps · 5.1 min read paywalled
#javascript #react #frontend #best-practices #programming
Open on Medium ↗
Wiki topics: 💻 · Programming 🌐 · Web Development

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:

  1. Initial app load
  2. After 15 minutes
  3. 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