How to Find and Fix Memory Leaks in JavaScript

A practical guide to finding and fixing memory leaks in JavaScript. Covers heap snapshot comparison, allocation timeline, performance.memory API, identifying retainer chains, fixing common patterns, automated leak testing, and building a memory monitoring dashboard.

JavaScriptadvanced
17 min read

Finding memory leaks requires systematic measurement, not guesswork. This guide covers practical techniques for detecting leaks using browser APIs, DevTools, and automated testing, then fixing them with proven patterns.

For the theory behind leak patterns, see Fixing JavaScript Memory Leaks: Complete Guide.

Measuring Memory with performance.memory

javascriptjavascript
class MemoryMonitor {
  constructor(intervalMs = 5000) {
    this.interval = intervalMs;
    this.readings = [];
    this.timerId = null;
    this.baselineHeap = null;
  }
 
  start() {
    if (!performance.memory) {
      console.warn("performance.memory not available (Chrome only)");
      return;
    }
 
    this.baselineHeap = performance.memory.usedJSHeapSize;
    this.timerId = setInterval(() => this.record(), this.interval);
    this.record(); // Initial reading
  }
 
  record() {
    const mem = performance.memory;
    const reading = {
      timestamp: Date.now(),
      usedHeap: mem.usedJSHeapSize,
      totalHeap: mem.totalJSHeapSize,
      limit: mem.jsHeapSizeLimit,
      deltaFromBaseline: mem.usedJSHeapSize - this.baselineHeap,
    };
 
    this.readings.push(reading);
    return reading;
  }
 
  getGrowthRate() {
    if (this.readings.length < 2) return 0;
 
    const first = this.readings[0];
    const last = this.readings[this.readings.length - 1];
    const deltaBytes = last.usedHeap - first.usedHeap;
    const deltaSec = (last.timestamp - first.timestamp) / 1000;
 
    return deltaSec > 0 ? deltaBytes / deltaSec : 0;
  }
 
  isLeaking(thresholdBytesPerSec = 1024) {
    const rate = this.getGrowthRate();
    return rate > thresholdBytesPerSec;
  }
 
  getSummary() {
    if (this.readings.length === 0) return null;
 
    const heaps = this.readings.map((r) => r.usedHeap);
    return {
      readings: this.readings.length,
      minHeapMB: (Math.min(...heaps) / (1024 * 1024)).toFixed(2),
      maxHeapMB: (Math.max(...heaps) / (1024 * 1024)).toFixed(2),
      currentMB: (heaps[heaps.length - 1] / (1024 * 1024)).toFixed(2),
      growthRateKBs: (this.getGrowthRate() / 1024).toFixed(2),
      isLeaking: this.isLeaking(),
    };
  }
 
  stop() {
    if (this.timerId) {
      clearInterval(this.timerId);
      this.timerId = null;
    }
  }
 
  reset() {
    this.stop();
    this.readings = [];
    this.baselineHeap = null;
  }
}
 
// Usage
const monitor = new MemoryMonitor(3000);
monitor.start();
 
// Check after some activity
setTimeout(() => {
  console.log(monitor.getSummary());
  monitor.stop();
}, 30000);

Heap Snapshot Comparison Workflow

The three-snapshot technique isolates leaks from normal allocations:

javascriptjavascript
// Automated snapshot-like comparison using object tracking
class HeapComparator {
  constructor() {
    this.snapshots = new Map();
  }
 
  captureCounters(label) {
    const counts = {
      label,
      timestamp: Date.now(),
      domNodes: document.querySelectorAll("*").length,
      eventListeners: this.estimateListenerCount(),
      timers: this.getActiveTimerCount(),
    };
 
    if (performance.memory) {
      counts.heapUsed = performance.memory.usedJSHeapSize;
    }
 
    this.snapshots.set(label, counts);
    return counts;
  }
 
  estimateListenerCount() {
    // Traverse DOM and count via getEventListeners (DevTools only)
    // In production, maintain a manual counter
    let count = 0;
    const walker = document.createTreeWalker(
      document.body,
      NodeFilter.SHOW_ELEMENT
    );
 
    while (walker.nextNode()) {
      const el = walker.currentNode;
      // Check common inline handlers
      const handlers = [
        "onclick", "onmousedown", "onmouseup", "onkeydown",
        "onkeyup", "onscroll", "onresize", "oninput", "onchange",
      ];
      handlers.forEach((h) => {
        if (el[h]) count++;
      });
    }
 
    return count;
  }
 
  getActiveTimerCount() {
    // Approximation: this requires wrapping setTimeout/setInterval
    return window.__timerCount || 0;
  }
 
  compare(labelA, labelB) {
    const a = this.snapshots.get(labelA);
    const b = this.snapshots.get(labelB);
 
    if (!a || !b) return null;
 
    return {
      domNodesDelta: b.domNodes - a.domNodes,
      listenersDelta: b.eventListeners - a.eventListeners,
      heapDeltaMB: a.heapUsed && b.heapUsed
        ? ((b.heapUsed - a.heapUsed) / (1024 * 1024)).toFixed(2)
        : "N/A",
      timeDeltaMs: b.timestamp - a.timestamp,
    };
  }
}
 
// Three-snapshot workflow
const comparator = new HeapComparator();
 
// 1. Baseline after page load
comparator.captureCounters("baseline");
 
// 2. Perform action (open modal, navigate, etc.)
openModal();
comparator.captureCounters("after-open");
 
// 3. Undo action (close modal, navigate back)
closeModal();
comparator.captureCounters("after-close");
 
// Compare baseline vs after-close: deltas should be near zero
console.log(comparator.compare("baseline", "after-close"));

Timer Wrapping for Leak Detection

javascriptjavascript
function installTimerTracking() {
  const originalSetTimeout = window.setTimeout;
  const originalSetInterval = window.setInterval;
  const originalClearTimeout = window.clearTimeout;
  const originalClearInterval = window.clearInterval;
 
  const activeTimers = new Map();
  window.__timerCount = 0;
 
  window.setTimeout = function (callback, delay, ...args) {
    const id = originalSetTimeout.call(
      window,
      function () {
        activeTimers.delete(id);
        window.__timerCount = activeTimers.size;
        callback.apply(this, args);
      },
      delay
    );
 
    activeTimers.set(id, {
      type: "timeout",
      delay,
      stack: new Error().stack,
      createdAt: Date.now(),
    });
    window.__timerCount = activeTimers.size;
 
    return id;
  };
 
  window.setInterval = function (callback, delay, ...args) {
    const id = originalSetInterval.call(window, callback, delay, ...args);
 
    activeTimers.set(id, {
      type: "interval",
      delay,
      stack: new Error().stack,
      createdAt: Date.now(),
    });
    window.__timerCount = activeTimers.size;
 
    return id;
  };
 
  window.clearTimeout = function (id) {
    activeTimers.delete(id);
    window.__timerCount = activeTimers.size;
    return originalClearTimeout.call(window, id);
  };
 
  window.clearInterval = function (id) {
    activeTimers.delete(id);
    window.__timerCount = activeTimers.size;
    return originalClearInterval.call(window, id);
  };
 
  return {
    getActive: () => [...activeTimers.entries()],
    getLeaked: (maxAgeMs = 60000) => {
      const now = Date.now();
      return [...activeTimers.entries()].filter(
        ([, info]) => now - info.createdAt > maxAgeMs
      );
    },
  };
}
 
const timerTracker = installTimerTracking();
 
// Later: check for leaked timers
const leaked = timerTracker.getLeaked(30000);
leaked.forEach(([id, info]) => {
  console.warn(`Leaked ${info.type} (${info.delay}ms):`, info.stack);
});

Automated Leak Test Pattern

javascriptjavascript
async function testForLeaks(actionFn, undoFn, iterations = 5) {
  // Force GC if available (Chrome with --expose-gc flag)
  const gc = window.gc || (() => {});
 
  const results = [];
 
  for (let i = 0; i < iterations; i++) {
    gc();
    await new Promise((r) => setTimeout(r, 100));
 
    const before = performance.memory
      ? performance.memory.usedJSHeapSize
      : 0;
    const domBefore = document.querySelectorAll("*").length;
 
    // Perform action
    await actionFn();
    await new Promise((r) => setTimeout(r, 50));
 
    // Undo action
    await undoFn();
    await new Promise((r) => setTimeout(r, 50));
 
    gc();
    await new Promise((r) => setTimeout(r, 200));
 
    const after = performance.memory
      ? performance.memory.usedJSHeapSize
      : 0;
    const domAfter = document.querySelectorAll("*").length;
 
    results.push({
      iteration: i + 1,
      heapDelta: after - before,
      domDelta: domAfter - domBefore,
    });
  }
 
  const avgHeapDelta =
    results.reduce((sum, r) => sum + r.heapDelta, 0) / results.length;
  const avgDomDelta =
    results.reduce((sum, r) => sum + r.domDelta, 0) / results.length;
 
  return {
    results,
    avgHeapDeltaKB: (avgHeapDelta / 1024).toFixed(2),
    avgDomDelta,
    leakDetected: avgHeapDelta > 10240 || avgDomDelta > 0,
  };
}
 
// Usage: test that a modal does not leak
const leakReport = await testForLeaks(
  () => document.getElementById("open-modal").click(),
  () => document.getElementById("close-modal").click(),
  10
);
 
console.log("Leak test:", leakReport);

Retainer Chain Analysis

Understanding retainer chains is key to finding the root cause:

javascriptjavascript
// Common retainer patterns and how to break them
 
// Pattern 1: Global reference chain
// window -> app -> components -> detachedDiv -> eventHandler -> largeData
// Fix: nullify the component reference on removal
class ComponentManager {
  constructor() {
    this.components = new Map();
  }
 
  register(id, component) {
    this.components.set(id, component);
  }
 
  remove(id) {
    const component = this.components.get(id);
    if (component && component.destroy) {
      component.destroy();
    }
    this.components.delete(id); // Break the chain
  }
}
 
// Pattern 2: Map key retaining values
// Fix: Use WeakMap when keys are objects
const nodeDataStrong = new Map();   // Leaks: retains nodes
const nodeDataWeak = new WeakMap(); // Safe: allows GC
 
function attachData(node, data) {
  nodeDataWeak.set(node, data);
  // When `node` is removed and dereferenced, entry is auto-cleaned
}
 
// Pattern 3: Promise chain retaining scope
// Fix: Do not store resolved promises long-term
async function processItems(items) {
  const results = [];
 
  for (const item of items) {
    const result = await processItem(item);
    results.push(result);
    // Do NOT push the promise itself, only the resolved value
  }
 
  return results;
}
Retainer TypeHow to IdentifyHow to Fix
Event listenerDevTools > Elements > Event Listeners tabRemove listener or use AbortController
Closure scopeHeap snapshot > Retainers > "(closure)"Null out captured variables
Map/Set entrySearch heap for Map/Set with growing sizeUse WeakMap/WeakSet for object keys
Global variableSearch window properties in ConsoleUse modules; avoid global state
Detached DOM treeHeap snapshot > filter "Detached"Clear JS references after removeChild
Console.logLogged objects retained by consoleClear console or avoid logging large objects
Rune AI

Rune AI

Key Insights

  • Three-snapshot technique isolates leaks: Capture baseline, perform action, undo action, then compare baseline to final; any positive delta indicates a leak
  • performance.memory tracks heap trends: Sample over time and compute growth rate to detect sustained leaks, but use DevTools for precise object-level analysis
  • Timer wrapping catches forgotten intervals: Instrument setTimeout/setInterval to track active timers, their creation stacks, and their age for leak detection
  • Automated leak tests are repeatable: Run action/undo cycles programmatically with heap measurements to create regression tests for memory leaks
  • Retainer chains reveal root causes: Follow the chain from a leaked object back through closures, event listeners, maps, and globals to find the reference preventing collection
RunePowered by Rune AI

Frequently Asked Questions

Can console.log cause memory leaks?

Yes. Objects logged to the console are retained by the DevTools console as long as it is open. Large objects or frequent logging in loops can accumulate significant memory. Use conditional logging in development and remove or disable console.log in production. Clearing the console releases these references.

How accurate is performance.memory?

It is a Chrome-only API that provides rough estimates. The values update periodically, not in real-time. Use it for trend analysis (is memory growing over time?) rather than precise measurements. For accurate profiling, use the DevTools Memory tab with heap snapshots. See [Using Chrome DevTools for JS Performance Tuning](/tutorials/programming-languages/javascript/using-chrome-devtools-for-js-performance-tuning).

Do arrow functions leak differently than regular functions?

No. Both arrow functions and regular functions create closures that capture their surrounding scope. The leak risk is identical. The only difference is `this` binding, which does not affect memory retention. Focus on what variables the function's scope captures, not the function syntax.

How do I detect leaks in production?

Use the Performance Observer API or `performance.memory` (Chrome) to sample heap size periodically and send the data to your analytics backend. Set alerts for sustained growth patterns. For Node.js servers, use `process.memoryUsage()` and monitor `heapUsed` over time.

Why does memory not decrease immediately after removing references?

The garbage collector runs at its own schedule, not immediately when references are cleared. V8 uses generational collection: short-lived objects are collected quickly, but promoted objects in old space are collected less frequently. Forcing GC with `--expose-gc` and `global.gc()` is for testing only. See [How V8 Garbage Collector Works](/tutorials/programming-languages/javascript/how-v8-garbage-collector-works-in-javascript) for details.

Conclusion

Finding memory leaks requires systematic measurement with performance.memory, heap comparison workflows, timer tracking, and automated action/undo test cycles. Fix leaks by breaking retainer chains through AbortController cleanup, nulling closure references, clearing timers, and using WeakMap/WeakRef for caches. For leak patterns and fixes, see Fixing JavaScript Memory Leaks: Complete Guide. For garbage collection internals, see JavaScript Garbage Collection: Complete Guide.