BugMojoBugMojoBugMojo
FeaturesPricingBlogHelpAbout
Add to ChromeLog inGet started
BugMojoBugMojo

Bug reports that actually help fix bugs — capture, replay, share.

A product of Softech Infra.

Product

  • Features
  • Pricing
  • Browser extension
  • Get started
  • Log in

Resources

  • Help & guides
  • Blog
  • Compare
  • Glossary

Company

  • About
  • Contact
  • Security
  • Privacy
  • Terms
  • Sitemap
© 2026 BugMojo. All rights reserved.
AllGuidesEngineeringPlaybooksCompareGlossaryAlternativesBy roleBug tracking by framework
  1. Home
  2. Blog
  3. Guides
  4. How to Debug a Memory Leak in JavaScript
Guide

How to Debug a Memory Leak in JavaScript

A memory leak is retained memory the garbage collector can't reclaim because something still references it. Here are the common causes, the Chrome DevTools three-snapshot technique, and the fixes mapped to each one.

Hrishikesh BaidyaHrishikesh Baidya·Jul 16, 2026·8 min read
Guides
Isometric line-art of three heap snapshots growing larger across a timeline, the last one cracked as retained memory leaks, lime on a dark canvas
TL;DR
  • What a leak is: memory you no longer need but the garbage collector can't free, because something still references it.
  • The usual causes: event listeners never removed, detached DOM nodes still referenced, ever-growing caches and Maps, large closures, uncleared timers, and missing useEffect cleanup in React.
  • The core technique: take a heap snapshot, repeat the suspect interaction, take a second snapshot, and compare the retained size and objects allocated between them.
  • The fixes: removeEventListener, clearInterval, a WeakMap or WeakRef for caches, and a cleanup return from every effect.

A memory leak is one of the hardest bugs to pin down because nothing crashes at the moment it happens. The page just gets slower the longer it stays open, and by the time anyone notices, the interaction that caused it scrolled off the screen twenty minutes ago. This guide is the hands-on companion to our definition of a memory leak: less about what a leak is and more about the exact workflow for finding one, from the Chrome DevTools Memory panel to the fixes that map to each cause.

What a JavaScript memory leak actually is

A JavaScript memory leak is memory that stays retained and cannot be garbage-collected because something still references it, even though your code no longer needs it. The collector frees only objects it can prove are unreachable from the root, so a single forgotten reference keeps the whole object graph alive and the heap grows for the life of the page.

Every object you create lives on the heap, and the engine reclaims it automatically with a mark-and-sweep collector that starts from a set of roots and frees anything it cannot reach by following references. A leak is not the collector failing. It is the collector working exactly as designed on a graph where your data is still reachable through a path you forgot about. You cannot force a collection from JavaScript, so the only lever you have is reachability: remove the last reference and the memory is eligible to be freed. That single fact shapes both the debugging and the fix.

The common causes, in code

Almost every front-end leak is one half of a missing pair: you registered something on the way in and never unregistered it on the way out. Learn to spot these six shapes in review and you will catch most leaks before a profiler ever gets involved.

  • Event listeners never removed. You call addEventListener but never the matching removeEventListener, so the handler closure — and everything it captured — stays alive as long as the target does.
  • Detached DOM nodes still referenced. You remove an element from the page, but a variable, array, or cache still points at it. Because a child holds a reference to its parent, one held node can pin an entire subtree.
  • Globals, caches, and Maps that grow without eviction. An array you only ever push to, or a Map keyed by user id with no eviction, is a leak with a slow fuse.
  • Closures capturing large scope. A closure stored somewhere durable that closes over a big object keeps that object reachable indefinitely.
  • Timers and intervals not cleared. A setInterval whose callback references state, never paired with clearInterval, leaks on every tick forever.
  • React-specific leaks. A useEffect that subscribes but returns no cleanup, or a subscription that keeps calling setState after unmount.
1-listener-and-timer-leak.jsjavascript
// LEAK: the listener and interval are never torn down.
function startDashboard() {
  const cache = [];
  window.addEventListener('resize', onResize);          // never removed
  setInterval(() => cache.push(readMetrics()), 1000);   // never cleared, array only grows
}

// FIX: keep the handles and tear everything down on teardown.
function startDashboard() {
  const onResize = () => layout();
  window.addEventListener('resize', onResize);
  const id = setInterval(sampleMetrics, 1000);

  return function stop() {
    window.removeEventListener('resize', onResize);
    clearInterval(id);
  };
}
2-detached-dom-and-growing-map.jsjavascript
// LEAK: the removed row is still referenced by a module-level Map,
// so the whole detached subtree can never be collected.
const rowCache = new Map();

function renderRow(id, data) {
  const el = document.createElement('tr');
  rowCache.set(id, el);   // strong reference
  return el;
}

function removeRow(id) {
  document.querySelector(`#row-${id}`)?.remove(); // gone from the DOM...
  // ...but rowCache still holds it — detached DOM node leak.
}

// FIX: drop the reference when you drop the node.
function removeRow(id) {
  document.querySelector(`#row-${id}`)?.remove();
  rowCache.delete(id);
}

The Chrome DevTools debugging workflow

Once you suspect a leak, stop guessing and run a controlled experiment in the Chrome DevTools Memory panel. The Chrome team draws a sharp diagnostic line first: a page whose performance gets progressively worse over time points to a leak, while one that is consistently slow points to memory bloat. The progressive-degradation curve is the leak signature, and confirming it before you profile saves you from chasing the wrong problem.

The three-snapshot technique

This is the single most reliable way to isolate a leak, and it works because a leak is an allocation that repeats and never frees:

  • Snapshot one. Load the page to a stable state and record a heap snapshot as your baseline.
  • Exercise the suspect, repeatedly. Perform the interaction you suspect several times — open and close a modal ten times, navigate a route back and forth, mount and unmount a list. Repetition is what makes a real leak grow linearly and stand out from one-off allocations.
  • Snapshot two, then compare. Record again and switch to the Comparison view. Sort by retained size and read the Objects allocated between snapshots delta: anything created during your repeated interaction and still alive is a leak candidate. Select it and read the Retainers pane at the bottom — that chain is the reference keeping it alive, and it is your bug.

To hunt detached nodes specifically, type Detached into the Class filter; the retainer chain will name the variable, array, or Map still pointing at the removed element.

Allocation instrumentation and the Performance monitor

Two other tools sharpen the picture. Allocation instrumentation on timeline (in the Memory panel) records allocations as they happen and draws blue bars for memory that survived to the end of the recording — the tall bars left standing after your interaction are exactly what leaked, and clicking one jumps to the allocating call stack. Separately, the Performance monitor (Command Menu → "Show Performance Monitor") plots a live JS heap size graph. Confirm a leak the honest way: interact with the page, then force garbage collection and watch the line. In a healthy app the heap sawtooths — it climbs, then drops back to baseline after each collection. In a leaking app the baseline ratchets upward and never returns, no matter how many times GC runs. That rising floor is your proof.

A snapshot tells you what leaked; a replay tells you what you did

A heap snapshot is a still photo of the leak, not the story of how it got there. It shows you a thousand detached rows, but not that a user opened the same modal forty times over half an hour. A long session replay reconstructs that timeline: BugMojo records the slow-degrading session as an rrweb replay and captures the console memory warnings inline, so you can scrub to the exact repeated interaction that grows the heap. That repeated action is precisely the input the three-snapshot technique needs — and the one a single snapshot can never reveal on its own.

Fixes mapped to each cause

Every fix is the same shape — release the last reference — but the mechanism differs by cause. Listeners get a matching removeEventListener (or an AbortSignal so they clean up in one call). Timers get clearInterval/clearTimeout. Detached nodes get their cache entry deleted alongside the DOM removal. And for caches and per-object metadata, the durable fix is to stop holding strong references at all: a WeakMap holds its keys weakly, so when the key object is otherwise unreachable, the collector is free to reclaim both the key and its cached value with no manual eviction.

3-weakmap-cache-and-effect-cleanup.jsjavascript
// FIX for a growing cache: WeakMap lets entries disappear with their key.
// When the node is removed and nothing else references it, the collector
// reclaims the node AND its cached metadata automatically.
const measurements = new WeakMap();

function measure(node) {
  if (!measurements.has(node)) {
    measurements.set(node, node.getBoundingClientRect());
  }
  return measurements.get(node);
}

// FIX for React: every subscription in an effect returns its cleanup.
function useLiveData(channel) {
  useEffect(() => {
    const sub = client.subscribe(channel, onMessage);
    const id = setInterval(poll, 5000);
    // The cleanup runs on unmount AND before every re-run —
    // no stale subscription, no orphaned interval.
    return () => {
      sub.unsubscribe();
      clearInterval(id);
    };
  }, [channel]);
}

For collections you genuinely want to iterate but still let go of, reach for a WeakRef or the entry-holding WeakSet. The rule that ties all of this together: bind every subscription — listeners, timers, fetches, observers — to a single cancellation token like an AbortController, and wire that token's abort() into your framework's teardown hook once. After that, cleanup is the default rather than a step you can forget, and the class of leak that comes from forgetting simply disappears.

The one thing to remember

A leak is a reachability bug: you can't force memory to free, you can only remove the last reference. Confirm it with a heap that never returns to baseline after GC, isolate it by repeating the interaction between two snapshots, and read the Retainers pane to find the reference you missed. Then remove the listener, clear the timer, delete the cache entry, or switch to a WeakMap — whichever one is still holding on.

⁓ ⁓ ⁓
Make slow leaks reproducible

Install the free BugMojo extension and capture the degrading session — replay, console, and network — so you see the exact repeated interaction that grew the heap, instead of guessing from a single snapshot.

Install the free extension

Frequently asked questions

Frequently asked questions

Sources

  1. Memory management — MDN reference: the heap, reachability, mark-and-sweep, and why you cannot manually trigger garbage collection — MDN Web Docs (2026)
  2. Fix memory problems — Chrome DevTools: memory leak vs. bloat, the Memory panel, and the Performance monitor JS heap graph — Chrome DevTools (2026)
  3. Record heap snapshots — Chrome DevTools: the comparison view, objects allocated between snapshots, retainers, and the Detached filter — Chrome DevTools (2026)
  4. WeakMap — MDN reference: weakly held keys for caches and per-object metadata that the collector can reclaim automatically — MDN Web Docs (2026)
Share:
Hrishikesh Baidya
Hrishikesh Baidya· Chief Technology Officer

Hrishikesh Baidya is the CTO at Softech Infra. He is drawn to architecture that is invisible — systems that simply work — and leads the engineering behind BugMojo.

On this page

  • What a JavaScript memory leak actually is
  • The common causes, in code
  • The Chrome DevTools debugging workflow
  • The three-snapshot technique
  • Allocation instrumentation and the Performance monitor
  • Fixes mapped to each cause

Get bug-tracking insights, weekly.

Engineering deep-dives, QA playbooks, and honest tool comparisons. No spam — unsubscribe in one click.