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.
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
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:
// 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
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
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:
// 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 Type | How to Identify | How to Fix |
|---|---|---|
| Event listener | DevTools > Elements > Event Listeners tab | Remove listener or use AbortController |
| Closure scope | Heap snapshot > Retainers > "(closure)" | Null out captured variables |
| Map/Set entry | Search heap for Map/Set with growing size | Use WeakMap/WeakSet for object keys |
| Global variable | Search window properties in Console | Use modules; avoid global state |
| Detached DOM tree | Heap snapshot > filter "Detached" | Clear JS references after removeChild |
| Console.log | Logged objects retained by console | Clear console or avoid logging large objects |
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/setIntervalto 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
Frequently Asked Questions
Can console.log cause memory leaks?
How accurate is performance.memory?
Do arrow functions leak differently than regular functions?
How do I detect leaks in production?
Why does memory not decrease immediately after removing references?
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.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.