You're 20 minutes into using a web app and it's noticeably slower. The tab is using 900MB of RAM. Nothing crashed — the app technically works. But it's dying quietly. Finding JavaScript memory leaks without long profiling sessions is the skill that separates apps that stay fast from ones that degrade over time.
What actually causes JavaScript memory leaks
The garbage collector reclaims memory when nothing references an object anymore. Leaks happen when your code holds references longer than intended — and usually doesn't know it.
Detached DOM nodes
You remove an element from the DOM but keep a JavaScript reference to it somewhere — in a closure, a global variable, or a cached list. The element is no longer on the page, but it can't be garbage-collected because your code still points at it. These are called detached DOM nodes.
Event listener accumulation
Adding event listeners inside a function that runs on every re-render or route change — without removing old ones — causes steady buildup. After 50 route changes, you have 50 listeners on the same element.
- Always pair
addEventListenerwith a matchingremoveEventListener - In React: return a cleanup function from every
useEffectthat registers listeners - Use
AbortControllerwhen you need to remove multiple listeners at once
Closures holding large objects
A closure captures everything in its outer scope. A long-lived callback — like a setInterval — keeps every object it references alive for as long as it runs. This often happens in data-fetching loops that accumulate response objects over time.
Diagnosing slow web app memory growth without a profiler
Open Chrome Task Manager with Shift+Esc and watch the Memory column while using the app. Healthy memory usage goes up and down. A leak shows as a line that only goes up, never returning to its previous baseline after a garbage collection cycle.
The 10-repetition reproduction pattern
Perform one interaction — open a modal, navigate to a route and back, submit a form. Note memory. Repeat 10 times. If memory after 10 repetitions is meaningfully higher than after 1, you've isolated the leak to that specific interaction. This narrows a vague "the app is slow" report into a specific, reproducible scenario.
React-specific patterns that leak
- Missing useEffect cleanup — subscriptions, intervals, and event listeners set up inside effects must be torn down in the cleanup return function, or they outlive the component
- Stale closures in setInterval — the closure captures the initial state value; use
useRefto hold values that change over time - Context value re-creation — creating new object literals inline in a Context's value prop causes all consumers to re-render on every parent render, accumulating subscriptions over time
Using Chrome's heap snapshot to find the leak source
Once you've confirmed memory is growing, the heap snapshot in Chrome DevTools tells you exactly what's alive and why. Open DevTools, go to the Memory tab, and you'll see three options: Heap snapshot, Allocation instrumentation on timeline, and Allocation sampling. For leak hunting, start with Heap snapshot.
Take a baseline snapshot before you do anything. Then perform the interaction you suspect is leaking — open and close that modal, navigate away and back. Force a garbage collection by clicking the trash icon in the Memory panel. Now take a second snapshot.
Here's the key move: switch the dropdown from "Summary" to "Comparison." This view shows you the delta — objects allocated between snapshot 1 and snapshot 2, minus anything that got cleaned up. You're looking for rows with a high positive "# New" count. Sort by "# New" descending and scan the top results.
When you see Detached HTMLElement or Detached HTMLDivElement in the list, that's the smoking gun. Click any row and the Retainers panel at the bottom of the screen shows you the chain: what object holds a reference to the leaking element, and what holds a reference to that object, all the way up to the root. Nine times out of ten, you'll see a closure, a component instance, or a global store entry somewhere in that chain — and that tells you exactly where to add your cleanup.
Reading the Retainers chain
The Retainers panel reads bottom-up: the bottom is the GC root, the top is the leaking object. Look for your own code in the middle — module names you recognize, variable names that match your codebase. That's the reference you need to break.
The three-snapshot technique
A simple before/after snapshot comparison has noise. When you take snapshot 2 right after an interaction, you catch temporary allocations that were already being reclaimed — promise microtasks settling, React's reconciler doing its last pass, event queue draining. These show up as "new" objects even though they'd disappear a moment later. You end up chasing ghosts.
The three-snapshot technique filters that out. Here's the sequence:
- Snapshot 1: baseline, page loaded and idle
- Perform the interaction once, then force GC, then take Snapshot 2
- Perform the same interaction again, force GC again, take Snapshot 3
- Compare Snapshot 3 against Snapshot 2 — not against Snapshot 1
Anything that survived from snapshot 2 to snapshot 3 wasn't a temporary allocation. It's genuinely retained across two full cycles of the interaction. This eliminates most false positives. You're now looking at the real leak, not warm-up overhead or engine internals.
We used exactly this approach when tracking down a leak in the onHover Chrome extension itself. Our popup panel was holding onto page DOM references after closing. A single before/after comparison kept showing a dozen objects we couldn't identify. The three-snapshot method narrowed it to two: a MutationObserver callback and a WeakRef that wasn't actually weak in the way we thought. Without that second repeat cycle, we'd have been investigating the wrong thing for hours.
WeakMap and WeakRef: letting garbage collection do its job
Sometimes the leak isn't a mistake — it's a design choice that made sense at the time. You cache DOM-related data in a plain Map keyed by element. The element gets removed from the page. But your Map still holds the key, which means it still holds the element, which means GC can't touch it.
The fix is to use WeakMap instead. A WeakMap holds its keys weakly — when the element has no other strong references, the GC can collect it along with its entry in the WeakMap. You don't have to manually delete anything. The cache cleans itself.
WeakRef is a step further — it lets you hold an optional reference that doesn't prevent collection at all. You call .deref() to get the value, which returns undefined if the object has already been collected. This is useful for notification caches, tooltip position caches, and other data you'd like to keep around if possible but don't mind losing. The gotcha: you can't rely on WeakRef timing. GC decides when to collect, not you. Use it for true optional caches, not anything load-bearing.
WeakMap limitations
WeakMap keys must be objects — you can't use primitives. And you can't iterate a WeakMap or check its size, which is actually a feature: if you could iterate it, the engine would have to keep all keys alive to show them to you. The constraint enforces the weak semantics.
Spotting leaks with the onHover Page Insights panel
Full DevTools heap profiling is powerful, but it takes setup time. You have to open DevTools, navigate to the Memory tab, remember the snapshot workflow, and then interpret what you see. If you're doing a quick sanity check while building a feature — not a dedicated debugging session — that overhead adds up.
The onHover Chrome extension's Page Insights panel shows live JS heap size as you interact with the page. It's sitting in a sidebar while you work. You open a modal, you close it, you watch if heap comes back down. You navigate to a route and back. You watch the number. No switching tools, no taking snapshots, no comparison views. If the number keeps climbing and never settles, you've got a signal worth investigating. If it breathes normally — up on interaction, down after — you're probably fine.
The workflow we landed on is two-stage. First, do a quick check with the onHover developer toolkit while building: if heap trends up steadily over 5-10 interactions, flag it. Second, if you see a signal, then open DevTools and run the three-snapshot technique to find the exact object. The first stage takes zero setup time and catches most real leaks before they reach review. The second stage only runs when you actually have evidence something is wrong — not as a precaution every time you ship a component.
We found a leak in our own extension this way. During onHover's development, we noticed the heap counter in the panel was slowly climbing while we clicked through different pages. We hadn't set up any profiling. We were just watching our own tool's output. Turned out a tooltip positioning cache was accumulating entries for elements that had scrolled out of view. Two lines of cleanup code, leak gone. We probably wouldn't have noticed it for months otherwise.
Memory leaks don't announce themselves. By the time users complain, the problem has existed for months. The difference between finding a leak early and chasing it through production logs is usually just having a quick feedback loop during development — something that shows you heap trend while you work, not after you've already shipped. Check memory growth as you build features, not after production complaints arrive. Catching leaks early costs minutes. Finding them after release costs weeks. And if you've shipped something that's already degrading, start with the three-snapshot technique. You'll have a name to search your codebase for within 20 minutes.