Finding JavaScript Memory Leaks Without a Profiler

Sourabh R.

Founder

9 min read

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.

Classic detached DOM leak
let detached;
function createLeak() {
const el = document.createElement('div');
detached = el; // Global ref keeps it alive
document.body.removeChild(el); // Removed from DOM...
// ...but el still referenced by detached
}

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 addEventListener with a matching removeEventListener
  • In React: return a cleanup function from every useEffect that registers listeners
  • Use AbortController when 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 useRef to 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.

Map vs WeakMap for DOM caching
// Strong reference — element can never be GC'd
const cache = new Map();
cache.set(domElement, metadata);
// Weak reference — GC can collect domElement freely
const cache = new WeakMap();
cache.set(domElement, metadata);
// Element removed from DOM — Map entry stays, WeakMap entry gone

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.

Frequently asked questions

What is a JavaScript memory leak?
A JavaScript memory leak occurs when the application allocates memory for objects but fails to release that memory when the objects are no longer needed. Because JavaScript uses garbage collection, memory is normally freed automatically when references are dropped. A leak happens when references to objects are unintentionally retained — in event listener registrations, closures, global variables, or cached data structures — preventing the garbage collector from releasing them.
How do I detect a memory leak in a web application?
The first signal is performance degradation over time — the app becomes progressively slower after 10–20 minutes of use even without heavy activity. Confirm it by watching the JavaScript heap size in Chrome DevTools' Performance panel or Task Manager over time; a constantly growing heap that never shrinks indicates a leak. The three-snapshot technique in DevTools (take snapshot, perform action, take snapshot, compare) shows which objects were created and not released between the two actions.
What are the most common causes of JavaScript memory leaks?
The three most common patterns are: (1) Forgotten event listeners — adding listeners to long-lived elements (document, window) without a corresponding removeEventListener causes listener accumulation as the page runs. (2) Detached DOM nodes — elements removed from the DOM but still referenced by JavaScript variables, preventing garbage collection. (3) Closures that capture large objects — functions that close over references to objects that should be temporary, keeping them alive as long as the function reference exists.
What is a detached DOM node in JavaScript?
A detached DOM node is an element that has been removed from the page's DOM (e.g., via removeChild or innerHTML replacement) but is still referenced by a JavaScript variable, array, or closure. Because the reference exists, the garbage collector cannot free the memory. The element tree rooted at the detached node stays in memory indefinitely. In Chrome DevTools' Memory panel, searching for 'Detached' in a heap snapshot reveals all detached nodes and their retaining paths.
How do I fix a memory leak from event listeners?
The fix is to pair every addEventListener with a corresponding removeEventListener when the element or component is unmounted or no longer needed. In React, this means cleaning up in useEffect's return function: useEffect(() => { document.addEventListener('scroll', handler); return () => document.removeEventListener('scroll', handler); }, []). A useful diagnostic technique is checking getEventListeners(element) in the Chrome console — it lists all listeners on a specific element.
How do I use Chrome DevTools to find a memory leak?
Open Chrome DevTools, go to the Memory tab, and take a heap snapshot. Perform the action you suspect is leaking (open a modal, scroll, navigate). Take another snapshot. In the second snapshot, use the 'Comparison' view to see the delta — objects that were allocated and not garbage collected between the two snapshots. Focus on objects with the highest shallow size delta. Click any object to see its retaining path — the chain of references keeping it alive.
How do I detect memory leaks in React components?
React-specific leaks typically come from: useEffect that sets up subscriptions, timers, or event listeners without a cleanup function; setState calls on unmounted components (causes 'Can't perform a React state update on an unmounted component' warning); and large objects stored in component state that grow over time. The React DevTools Profiler shows component render counts and memory usage. For leak detection, the Chrome DevTools three-snapshot technique combined with filtering for React Fiber nodes is the most effective approach.
What is the difference between shallow size and retained size in a heap snapshot?
Shallow size is the amount of memory directly allocated by the object itself — just the object's own properties, not anything it references. Retained size is the total memory that would be freed if the object were garbage collected, including all objects that are only reachable through this object. A small object with a large retained size is the signature of a memory leak — the object is holding references to a large object tree that would otherwise be collectible.