Find and Fix JavaScript Memory Leaks with Chrome DevTools
AI generated
JS
() =>
JavaScript · Chrome DevTools · Performance · Memory
Find JavaScript Memory Leaks with Chrome DevTools
systematically and fix them for good

Memory leaks in JavaScript applications are invisible, until the browser crashes or the page noticeably slows down after a few minutes. Heap snapshots, allocation timelines and the retainer graph in Chrome DevTools make invisible memory leaks visible, locatable and fixable.

15 min read Heap Snapshot · Allocation Timeline · Retainer · detached DOM Chrome 120+ · JavaScript ES2024

1. How JavaScript memory management really works

JavaScript engines like V8 manage memory automatically through a garbage collector. The GC walks what is called the object graph: all objects reachable through a chain of references from the global scope or the active call stack. Objects that are no longer reachable are considered "garbage" and get freed. A memory leak occurs precisely when objects stay reachable even though the program no longer needs them. The GC cannot free them because a reference, often an unintended one, keeps the object anchored in the object graph.

V8 uses a generational GC: short-lived objects in the "New Space" are collected frequently (Minor GC), while long-lived objects migrate to the "Old Space" and are collected less often (Major GC / mark-compact). Memory leaks typically show up as continuous growth of the Old Space over time that does not fully shrink back even after Major GC cycles. This pattern is the first diagnostic signal: if the heap curve in Chrome DevTools lands a bit higher after each GC cycle than after the previous one, a memory leak is very likely present.

2. The most common causes of memory leaks

Four patterns account for the majority of all JavaScript memory leaks in practice. First: event listeners that are registered on DOM nodes which are later removed from the DOM without the listener being deregistered first with removeEventListener. The DOM node stays in memory because the event listener holds a reference to it, which is what's called a "detached DOM node". Second: closures that unintentionally hold on to variables from the surrounding scope. When a closure is stored inside a long-lived object, every variable visible inside that closure stays in memory.

Third: timers and intervals, a setInterval callback that is never stopped with clearInterval keeps every object referenced inside the callback alive for the entire lifetime of the page. Fourth: global variables and caches without a size limit. A Map or an array that is continuously filled with new entries without old entries ever being removed grows without bound. These four patterns are exactly what Chrome DevTools memory analysis targets, knowing them tells you what to look for when investigating a memory leak.

3. Chrome DevTools Memory panel overview

The Memory panel in Chrome DevTools (F12 → Memory) offers three profiling modes: "Heap snapshot" creates a point-in-time snapshot of every object on the heap. "Allocation on timeline" continuously records when objects are allocated and whether they survive the next GC cycle. "Allocation sampling" is a low-overhead profiling mode that breaks down memory usage by call stack using statistical sampling. For diagnosing memory leaks, heap snapshots and the allocation timeline are the most important tools.

Before any memory analysis, it's worth first watching in the Performance panel whether the heap grows over time. Start "Record", perform typical user interactions, then click "Collect garbage" (the trash can icon) and observe heap growth. A heap that keeps growing immediately after the GC click is the clearest signal of an active memory leak. Only then is it worth diving deeper into heap snapshots.

4. Heap snapshots: the three-snapshot technique

The most reliable method for finding memory leaks with heap snapshots is the three-snapshot technique. Snapshot 1: baseline after the page has loaded. Then perform the suspected action once (for example, open and close a dialog). Snapshot 2: state after the action. Perform the action again. Snapshot 3: second pass. In snapshot 3, select the dropdown view "Objects allocated between snapshot 1 and snapshot 2". Everything still visible there was allocated in step 1 and never freed, a clear sign of a memory leak.

In the snapshot view, the "Retainers" column shows why an object was not freed: which chain of references is holding it on the heap. A typical chain looks like: closure → function → event listener → DOM node. The retainer graph reads from bottom to top: the bottom-most element is the "GC root" (for example the window object), and the top-most element is the leaking object. Following this path lets you pinpoint exactly which reference is causing the memory leak.


// DevTools Memory Profiling: Heap Snapshot helper (paste in DevTools Console)

// Step 1: Baseline, run this, take Snapshot 1 in DevTools
console.log("Heap baseline ready, take Snapshot 1");

// Step 2: Simulate the leaky action
const leakyStore = [];
function createLeak() {
  const bigData = new Array(100_000).fill("leak data"); // 100k strings
  const handler = () => console.log(bigData.length);    // closure holds bigData
  document.body.addEventListener("click", handler);     // no cleanup stored
  // handler is never removed, so bigData stays alive as long as DOM does
  leakyStore.push(handler); // additional strong reference
}
createLeak();
console.log("Action done, take Snapshot 2");

// Step 3: Repeat action, then take Snapshot 3
// In Snapshot 3: switch dropdown to "Objects allocated between Snapshot 1 and 2"
// Look for Array(100000) entries, those are the leaked bigData arrays
// Check Retainers panel: closure → handler → EventListener → document.body

// Correct fix: store handler reference and remove it when done
let cleanHandler = null;
function createClean() {
  const data = new Array(100_000).fill("clean data");
  cleanHandler = () => console.log(data.length);
  document.body.addEventListener("click", cleanHandler);
}
function destroyClean() {
  document.body.removeEventListener("click", cleanHandler);
  cleanHandler = null; // release reference, GC can now collect data
}

5. Allocation timeline for ongoing leaks

While heap snapshots deliver point-in-time snapshots, the allocation timeline is better suited to memory leaks that build up over time, for example through a setInterval that creates new objects on every iteration, or a WebSocket handler that stores messages in a growing array. In the Memory panel select "Allocation instrumentation on timeline", start recording, use the application normally for 30 to 60 seconds, then stop recording. The view shows blue and gray bars: blue bars are allocations that are still on the heap at the point recording stopped, potential leaks. Gray bars are allocations that have already been collected.

Clicking on a blue bar filters down to the objects that were allocated in that time window and are still alive. The call stack at the time of allocation is shown, which is the most direct way to find the code causing the memory leak. With a setInterval leak you'll see recurring blue bars with an identical call stack that never turn gray. That's the visual fingerprint of a timer-based memory leak.

6. Finding detached DOM nodes

Detached DOM nodes are DOM elements that have been removed from the document tree but are still held in memory by JavaScript references. They cause one of the most common memory leaks in single-page applications, because frameworks like React or Vue internally create and remove DOM nodes while application code keeps references to the old nodes. In Chrome DevTools you can search directly for detached DOM nodes: take a heap snapshot, then type "Detached" into the search field at the top. Every entry of class "Detached HTMLElement", "Detached HTMLDivElement" and so on is a candidate for a memory leak.

For every detached node found, the Retainers panel shows which JavaScript variable holds the reference. Often it's a global variable, a closure in an event handler, or a data structure like a Map or a Set. The fix is always the same: when removing a DOM node, explicitly set all references to it and its child elements to null. In modern codebases, WeakRef helps for caches that reference DOM nodes, the GC can collect WeakRef targets without any explicit cleanup code needed.

7. Diagnosing event listener leaks

Event listeners are the single most common cause of memory leaks in JavaScript applications. Every listener registered with addEventListener holds a reference to the callback function and every variable captured by that callback's closure. If the listener is never deregistered, this entire object graph stays alive. Since Chrome 90, Chrome DevTools offers a direct way to inspect event listeners: in the Elements panel under "Event Listeners" you can see, for every DOM node, which listeners are registered and which part of the code they come from.

For a systematic analysis, using getEventListeners(element) in the DevTools console is worthwhile, this internal Chrome API returns an object listing the registered listeners for every event type. In an SPA that dynamically mounts and unmounts components, you should check after every unmount cycle whether the old node still has listeners attached. The AbortController pattern is the most modern solution: a controller is created, its signal is passed to every addEventListener call, and at cleanup time a single controller.abort() is enough to remove all listeners at once.


// Event listener leak patterns and fixes

// WRONG: listener added on each render, never removed
class ComponentLeak {
  mount() {
    // Each call to mount() adds another listener, none are ever removed
    document.addEventListener("keydown", (e) => {
      if (e.key === "Escape") this.close(); // closure holds `this`
    });
  }
  close() { /* ... */ }
}

// RIGHT: AbortController pattern, single abort() removes all listeners
class ComponentClean {
  #controller = null;

  mount() {
    this.#controller = new AbortController();
    const { signal } = this.#controller;

    // All listeners share the same signal, one abort() removes all
    document.addEventListener("keydown", (e) => {
      if (e.key === "Escape") this.close();
    }, { signal });

    window.addEventListener("resize", () => this.onResize(), { signal });
    document.addEventListener("click", (e) => this.onOutsideClick(e), { signal });
  }

  unmount() {
    this.#controller?.abort(); // removes all registered listeners at once
    this.#controller = null;
  }

  close() { this.unmount(); }
  onResize() { /* ... */ }
  onOutsideClick(e) { /* ... */ }
}

// Inspect listeners in DevTools console (Chrome only):
// getEventListeners(document)  → { keydown: [...], click: [...] }
// getEventListeners(document).keydown.length  → count of keydown listeners

8. Concrete fix strategies with code

Besides the AbortController pattern for event listeners, there are four more fix strategies that cover the majority of memory leaks in practice. For timer leaks: always store the return value of setInterval and setTimeout and stop it during cleanup with clearInterval or clearTimeout respectively. In React components, this belongs in the cleanup return of the useEffect function. For cache leaks: use WeakMap instead of Map whenever DOM nodes or objects serve as keys, WeakMap entries are automatically removed once the key has no other referrers.

For observer leaks (IntersectionObserver, MutationObserver, ResizeObserver): always call observer.disconnect() once the observer is no longer needed. Observers hold implicit references to the observed elements and the callback. For closure leaks in long-lived objects: explicitly set variables that are only needed temporarily to null once they're no longer needed, this signals to the GC that it can free the referenced object graph. These fixes are simple, but the discipline of applying them consistently is what separates a stable JavaScript application from a leaking one.

9. Leak patterns compared side by side

The following overview shows the most common memory leak patterns in JavaScript, along with the corresponding fix and the detection method in Chrome DevTools.

Leak type Cause Fix DevTools signal
Event listener addEventListener without removeEventListener AbortController + abort() Detached DOM, growing listener count
Timer setInterval without clearInterval Store the ID, clearInterval in cleanup Regular blue bars in the timeline
Closure Large variable in a long-lived closure Set the variable to null after use Large objects in the retainer path
Cache / Map Map grows without bound WeakMap or LRU cache with a limit Steadily growing Map instance on the heap
Observer MutationObserver without disconnect() observer.disconnect() in teardown MutationObserver in the retainer path

// WeakMap cache: entries are automatically GC'd when key has no other references
const cache = new WeakMap();

function processElement(el) {
  if (cache.has(el)) return cache.get(el);  // cache hit, no recompute

  const result = expensiveComputation(el);
  cache.set(el, result); // key is el (DOM node), GC-safe
  return result;
  // When el is removed from DOM and all JS refs drop, WeakMap entry is freed
}

// Timer cleanup in plain JS class
class PollingService {
  #intervalId = null;

  start(callback, ms = 5000) {
    this.#intervalId = setInterval(callback, ms);
  }

  stop() {
    clearInterval(this.#intervalId); // must always be called on teardown
    this.#intervalId = null;
  }
}

// Timer cleanup in React useEffect
// useEffect(() => {
//   const id = setInterval(fetchData, 5000);
//   return () => clearInterval(id); // React calls this on unmount
// }, []);

Mironsoft

JavaScript performance analysis and memory optimization

JavaScript application with memory problems?

We analyze JavaScript memory leaks with Chrome DevTools, identify the root causes on the heap and fix them for good, including a monitoring strategy for production.

Heap analysis

Three-snapshot technique and allocation timeline for leak localization

Code fixes

AbortController, WeakMap, observer teardown and timer cleanup

Monitoring

Integrate a performance budget and heap metrics into the CI pipeline

10. Summary

JavaScript memory leaks are not caused by bugs in the engine, but by unintended references that the garbage collector cannot break through. Systematic analysis with Chrome DevTools, the three-snapshot technique, the allocation timeline and the retainer graph, makes these invisible references visible. The most important causes are event listeners without deregistration, timers without cleanup, closures that hold on to large objects, and caches without a size limit. For every one of these leak types there's a clear pattern: AbortController, WeakMap, explicit null assignment and observer disconnect.

The decisive factor is not just fixing the memory leaks you find, but preventing them through consistent teardown discipline: every addEventListener needs a defined point at which removeEventListener runs, every setInterval needs a clearInterval, every observer needs a disconnect(). Anchoring this convention in a team's development standards, backed by automated memory tests in the CI pipeline, is the most effective defense against long-term memory problems in JavaScript applications.

JavaScript Memory Leaks: The Essentials at a Glance

Diagnostics

Three-snapshot technique in Chrome DevTools: baseline → action → comparison. Allocation timeline for ongoing leaks. Detached DOM search in the heap snapshot.

Event listeners

AbortController pattern: pass a signal to every addEventListener call, call abort() once during teardown. Eliminates the most common source of leaks.

Caches & Maps

WeakMap for DOM nodes as keys, GC-safe. LRU cache with a fixed size for every other cache. Never let a Map grow without bound.

Timers & Observers

Store the setInterval ID and call clearInterval in cleanup. Always disconnect() MutationObserver, IntersectionObserver and ResizeObserver.

11. FAQ: JavaScript Memory Leaks with Chrome DevTools

1What is a JavaScript memory leak?
Objects remain on the heap because an unintended reference blocks the GC. Memory usage grows continuously, without recovering after GC cycles.
2How do I detect a memory leak in Chrome DevTools?
Performance panel: watch the heap line after triggering GC. Memory panel: three-snapshot technique or allocation timeline, blue bars that never turn gray.
3What is the three-snapshot technique?
Baseline → action → snapshot 2 → action → snapshot 3. In snapshot 3, use the filter "Objects allocated between Snapshot 1 and 2", objects visible there are leak candidates.
4What are detached DOM nodes?
DOM elements removed from the tree but still on the heap because of JS references. Findable in a heap snapshot via the "Detached" search.
5How do I fix event listener leaks?
AbortController pattern: pass a signal to every addEventListener, call controller.abort() once during teardown. Removes all listeners at once.
6WeakMap instead of Map?
When DOM nodes or objects serve as keys. WeakMap entries are automatically deleted once the key has no other strong references left.
7Detecting timer leaks in the timeline?
Regularly recurring blue bars with an identical call stack in the allocation timeline that never turn gray, the fingerprint pattern of a timer leak.
8What does the retainer graph show?
The reference chain from the GC root to the leaking object. Read from bottom (window) to top (the leak). Shows exactly which reference needs to be freed.
9Avoiding observer leaks?
Always call observer.disconnect(), for MutationObserver, IntersectionObserver and ResizeObserver. Observers implicitly hold references to elements and the callback.
10Testing memory leaks automatically in CI?
Yes, with Puppeteer/Playwright and the Chrome DevTools Protocol (CDP): compare HeapProfiler.takeHeapSnapshot before and after actions. Define heap growth as a CI metric.