How to Hunt Front‑End Memory Leaks with Heap Snapshot Comparison
A memory leak in a frontend presents as a slow death: the tab's memory climbs over minutes or hours of use, the app gets progressively sluggish, and eventually it crashes or the browser kills it. Leaks are among the scariest bugs to hunt because the symptom (growing memory) is far removed from the cause (something holding a reference it shouldn't), and the effect is gradual. But there's a rigorous, repeatable procedure — the heap snapshot comparison — that turns "memory is growing, somewhere" into "this object type is accumulating, retained by this reference." The playbook in one line: confirm growth with the Memory timeline, then take heap snapshots before and after a repeated action and diff them to find what accumulates and what retains it.
The mental model: JavaScript is garbage-collected, so a "leak" means an object that should be collectable is still reachable from a GC root (a global, a closure, a live DOM node, an event listener, a timer). The hunt is about finding the retaining path — the chain of references keeping the object alive. In React frontends, leaks cluster around a few culprits: subscriptions/listeners/timers/observers not cleaned up in useEffect, detached DOM nodes held by JS, closures capturing large objects, and caches that grow without bound.
Symptoms & First Signals
| Symptom | First signal |
|---|---|
| app slows down the longer it's open | DevTools → Performance monitor → JS heap size trends up and doesn't come back down |
| memory climbs with repeated navigation | navigate away and back N times → heap grows N× (should be flat) |
| tab crashes / "Aw, Snap! Out of memory" | the endgame of an unchecked leak |
| a component's data persists after unmount | detached nodes / retained instances in a heap snapshot |
GIF via GIPHY
The cheapest first signal: open DevTools → More tools → Performance monitor and watch "JS heap size" while you use the app (especially repeating an action). A healthy app sawtooths (grows, then GC drops it back). A leak stair-steps up — each cycle leaves memory higher than before, and GC never reclaims it. That up-only trend is your confirmation you have a real leak, not just normal allocation.
The Differential: Common Causes, Ranked
| # | Cause | Tell |
|---|---|---|
| 1 | listener/subscription not removed (addEventListener, store subscribe, socket) with no cleanup | leaks one per mount; grows with navigation/remounts |
| 2 | timer/interval not cleared (setInterval, setTimeout loops) | keeps a closure (and its captures) alive forever |
| 3 | observer not disconnected (IntersectionObserver, ResizeObserver, MutationObserver) | holds observed nodes → detached DOM |
| 4 | detached DOM nodes held by a JS reference (a ref, a cache, a closure) | heap snapshot shows "Detached HTMLElement" retained |
| 5 | unbounded cache/array growing forever (memoization without eviction) | one object type's count climbs indefinitely |
| 6 | stale closure capturing a large object (kept alive by #1/#2) | the retainer chain leads back to a closure |
GIF via GIPHY
Notice #1–#3 are all the same root React bug: a useEffect that sets something up but doesn't return a cleanup function. That single pattern is the most common frontend leak.
The Systematic Hunt
1. CONFIRM GROWTH (is it really a leak?):
Performance monitor → watch JS heap size while repeating an action (open/close a
modal, navigate a route, mount/unmount a component) ~10 times.
Force GC (the 🗑 trash icon in the Memory tab) after — does memory drop back?
drops back to baseline → NOT a leak (normal churn).
stays elevated / stair-steps → LEAK confirmed. Continue.
2. THE 3-SNAPSHOT TECHNIQUE (isolate what accumulates):
DevTools → Memory → Heap snapshot.
(a) Take snapshot 1 (baseline).
(b) Perform the leaking action N times (e.g. mount+unmount the suspect component 10×).
(c) Force GC, then take snapshot 2.
→ In snapshot 2, switch the dropdown to "Comparison" (vs snapshot 1).
→ Sort by "# Delta". Objects that grew by ~N (matching your repetitions) are the LEAK.
(e.g. "Detached HTMLDivElement +10", "MyComponent +10", "(closure) +10")
3. FIND THE RETAINING PATH (what's holding it):
Click a leaked object → the "Retainers" panel shows the reference chain keeping
it alive, from the object UP to a GC root.
→ follow the chain: it usually ends at an event listener, a timer's closure, an
observer, or a module-level array/Map. THAT reference is the bug.
4. FOR DETACHED DOM specifically:
In the snapshot, filter by "Detached" — detached DOM nodes are elements removed
from the document but still referenced by JS (so not collected). Their retainer
chain points at the JS that's holding them (often a ref stored in a cache/closure).
5. CONFIRM THE FIX:
add the cleanup / eviction, then REPEAT step 1–2. The delta for the leaked object
type should now be ~0. Snapshot comparison is the proof, not "seems better."
The comparison-snapshot diff is the heart of the technique:
GIF via GIPHY
snapshot 1 (baseline) ──[ do the action 10× + GC ]──► snapshot 2
│ │
└──────────────── COMPARE (# Delta) ──────────────────┘
objects with Δ ≈ +10 accumulated across the 10 repetitions → THE LEAK.
click one → RETAINERS panel → the reference chain → the line of code holding it.
Worked Scenario: The Listener That Wasn't Removed
Symptom: memory climbs each time the user opens and closes a chat panel. Hunt: Performance monitor confirms stair-step growth. 3-snapshot comparison (open/close 10×) shows ChatPanel instances +10 and (closure) +10 — they're not being collected. Retainers panel: the closures are held by window's resize event listeners. Root cause: a useEffect added window.addEventListener("resize", handler) with no cleanup (cause #1). Every mount added a listener that captured the component instance, and since window is a GC root, none were ever collected. Fix: return a cleanup from the effect: return () => window.removeEventListener("resize", handler). Re-run the comparison → delta is 0. This is the canonical React leak — every subscription-style effect needs a cleanup.
GIF via GIPHY
Worked Scenario: The Detached DOM
Symptom: memory grows as the user scrolls a feed. Hunt: heap snapshot, filter "Detached" → thousands of "Detached HTMLDivElement" retained. Retainers: they're held by an array cachedRows in a module-level cache that stores rendered row DOM nodes by id and never evicts. When rows scroll away and React unmounts them, the DOM nodes are detached — but the cache still references them, so they can't be collected. Root cause: detached DOM held by an unbounded cache (causes #4 + #5). Fix: don't cache DOM nodes (cache data, not elements), and bound the cache with an LRU eviction. Caching DOM nodes is almost always a mistake — it defeats GC.
GIF via GIPHY
Worked Scenario: The Observer Left Running
Symptom: slow leak on a page with many lazy-loaded images. Hunt: comparison shows IntersectionObserver +N and detached image elements retained by them. Root cause: an IntersectionObserver created per image in a useEffect was never disconnect()ed (cause #3) — the observer held the observed (now detached) elements. Fix: return () => observer.disconnect() in the effect cleanup (exactly the pattern from the infinite-scroll build). Observers hold their observed nodes; forgetting to disconnect leaks both.
GIF via GIPHY
Fix Patterns
- Every
useEffectthat sets something up must return a cleanup. Listeners →removeEventListener; intervals/timeouts →clearInterval/clearTimeout; observers →disconnect; subscriptions → the unsubscribe function; sockets →close. This one discipline prevents the majority of frontend leaks. React 18 StrictMode's double-invoke exists partly to surface missing cleanups. - Never store DOM nodes in JS caches. Cache data, and let React own the DOM. A JS reference to a removed node keeps it detached and uncollectable.
- Bound every cache. Memoization caches, request caches, and lookup maps that grow forever are leaks by another name — add LRU or size-based eviction.
- Break large-object captures. If a closure (a timer callback, a listener) captures a large object it doesn't need, refactor so it captures only what it uses — or use
WeakRef/WeakMapfor caches that shouldn't keep their keys alive. - Prefer AbortController for fetch and listeners. A single
AbortControllercan cancel a fetch and remove listeners (addEventListener(..., { signal })), making cleanup oneabort()call — fewer forgotten unsubscribes.
GIF via GIPHY
Key Takeaways
- A leak means an object that should be collectable is still reachable from a GC root — the hunt is finding the retaining path. Confirm it first with Performance monitor → JS heap size: a healthy app sawtooths (GC reclaims), a leak stair-steps up (it doesn't).
- The decisive technique is the 3-snapshot comparison: snapshot → repeat the action N times + force GC → snapshot → diff by # Delta. Objects that grew by ~N are the leak; the Retainers panel shows exactly what's holding them.
- The differential, ranked: unremoved listeners/subscriptions, uncleared timers, undisconnected observers, detached DOM held by JS, and unbounded caches — the first three are all the same React bug: a
useEffectwith no cleanup function. - For detached DOM, filter "Detached" in the snapshot and follow the retainer chain to the JS reference holding the removed node.
- Every setup needs a teardown (the universal fix), never cache DOM nodes, bound every cache, and confirm the fix by re-running the comparison — the delta should drop to ~0, which is proof, not "seems better."
GIF via GIPHYWhat did you think?