Fixing JavaScript Memory Leaks: Complete Guide
A complete guide to fixing JavaScript memory leaks. Covers identifying leak patterns, event listener leaks, closure leaks, timer leaks, detached DOM references, WeakRef and FinalizationRegistry, heap snapshots, memory profiling, and building a leak detection utility.
Memory leaks occur when your application retains references to objects that are no longer needed, preventing the garbage collector from reclaiming that memory. Over time, leaks cause increasing memory consumption, degraded performance, and eventual crashes.
For how garbage collection works internally, see JavaScript Garbage Collection: Complete Guide.
Common Leak Patterns
| Pattern | Cause | Fix |
|---|---|---|
| Event listeners | Not removing listeners on cleanup | Use removeEventListener or AbortController |
| Closures | Capturing large objects in scope | Null out references when done |
| Timers | setInterval never cleared | Store ID and call clearInterval |
| Detached DOM | Removed nodes still referenced in JS | Clear references after removal |
| Global variables | Accidental globals or growing arrays | Use let/const, clear collections |
| Observers | MutationObserver/IntersectionObserver not disconnected | Call disconnect() on cleanup |
Event Listener Leaks
The most common leak is attaching event listeners without removing them:
// LEAKY: listeners accumulate on every call
function setupHandler() {
const data = new Array(10000).fill("x"); // Large allocation
document.getElementById("button").addEventListener("click", () => {
console.log(data.length); // Closure retains `data`
});
}
// FIX 1: Named function + removeEventListener
function setupHandlerFixed() {
const data = new Array(10000).fill("x");
function handleClick() {
console.log(data.length);
}
const button = document.getElementById("button");
button.addEventListener("click", handleClick);
// Cleanup function
return () => {
button.removeEventListener("click", handleClick);
};
}
// FIX 2: AbortController (modern approach)
function setupHandlerAbort() {
const controller = new AbortController();
const data = new Array(10000).fill("x");
document.getElementById("button").addEventListener(
"click",
() => console.log(data.length),
{ signal: controller.signal }
);
// Cleanup: removes all listeners registered with this signal
return () => controller.abort();
}Closure Leaks
// LEAKY: closure retains entire scope
function createProcessor() {
const hugeBuffer = new ArrayBuffer(50 * 1024 * 1024); // 50 MB
const result = processBuffer(hugeBuffer);
// This closure retains `hugeBuffer` even though it only needs `result`
return function getResult() {
return result;
};
}
// FIX: Extract only what you need
function createProcessorFixed() {
let result;
{
const hugeBuffer = new ArrayBuffer(50 * 1024 * 1024);
result = processBuffer(hugeBuffer);
// hugeBuffer goes out of scope here
}
return function getResult() {
return result;
};
}
// FIX 2: Null out large references
function createProcessorFixed2() {
let hugeBuffer = new ArrayBuffer(50 * 1024 * 1024);
const result = processBuffer(hugeBuffer);
hugeBuffer = null; // Allow GC to reclaim
return function getResult() {
return result;
};
}Timer Leaks
// LEAKY: interval never cleared
class Poller {
start() {
this.data = new Array(100000).fill("x");
setInterval(() => {
this.poll();
}, 1000);
}
poll() {
console.log("Polling with", this.data.length, "items");
}
}
// FIX: Store and clear interval ID
class PollerFixed {
constructor() {
this.intervalId = null;
this.data = null;
}
start() {
this.data = new Array(100000).fill("x");
this.intervalId = setInterval(() => {
this.poll();
}, 1000);
}
poll() {
console.log("Polling with", this.data.length, "items");
}
stop() {
if (this.intervalId !== null) {
clearInterval(this.intervalId);
this.intervalId = null;
}
this.data = null;
}
}
// FIX 2: Use AbortController with a custom timer
function createAbortableInterval(callback, ms) {
const controller = new AbortController();
function tick() {
if (controller.signal.aborted) return;
callback();
setTimeout(tick, ms);
}
setTimeout(tick, ms);
return controller;
}
const timer = createAbortableInterval(() => console.log("tick"), 1000);
// Later: timer.abort();Observer Leaks
// LEAKY: observers never disconnected
function watchElement(element) {
const observer = new MutationObserver((mutations) => {
console.log("Changed:", mutations.length);
});
observer.observe(element, { childList: true, subtree: true });
// Observer keeps watching forever, retaining element reference
}
// FIX: Return cleanup function
function watchElementFixed(element) {
const observer = new MutationObserver((mutations) => {
console.log("Changed:", mutations.length);
});
observer.observe(element, { childList: true, subtree: true });
return () => {
observer.takeRecords(); // Process pending
observer.disconnect();
};
}
// FIX: In a component lifecycle
class Component {
constructor(element) {
this.element = element;
this.cleanups = [];
}
init() {
// Event listener with cleanup
const controller = new AbortController();
this.element.addEventListener("click", this.handleClick.bind(this), {
signal: controller.signal,
});
this.cleanups.push(() => controller.abort());
// Observer with cleanup
const observer = new IntersectionObserver(this.handleVisible.bind(this));
observer.observe(this.element);
this.cleanups.push(() => observer.disconnect());
}
handleClick() { /* ... */ }
handleVisible() { /* ... */ }
destroy() {
this.cleanups.forEach((fn) => fn());
this.cleanups = [];
this.element = null;
}
}WeakRef and FinalizationRegistry
// WeakRef: hold a reference that does not prevent GC
class Cache {
constructor() {
this.entries = new Map();
this.registry = new FinalizationRegistry((key) => {
console.log(`Object for "${key}" was garbage collected`);
this.entries.delete(key);
});
}
set(key, value) {
const ref = new WeakRef(value);
this.entries.set(key, ref);
this.registry.register(value, key);
}
get(key) {
const ref = this.entries.get(key);
if (!ref) return undefined;
const value = ref.deref();
if (value === undefined) {
this.entries.delete(key);
return undefined;
}
return value;
}
has(key) {
return this.get(key) !== undefined;
}
get size() {
// Clean stale entries
for (const [key, ref] of this.entries) {
if (ref.deref() === undefined) {
this.entries.delete(key);
}
}
return this.entries.size;
}
}
// Usage
const cache = new Cache();
let bigObject = { data: new Array(100000).fill("x") };
cache.set("report", bigObject);
console.log(cache.has("report")); // true
bigObject = null; // Allow GC
// After GC runs, cache.has("report") will return falseLeak Detection Utility
class LeakDetector {
constructor() {
this.snapshots = [];
this.trackedObjects = new Map();
this.registry = new FinalizationRegistry((label) => {
this.trackedObjects.delete(label);
console.log(`[LeakDetector] "${label}" was collected`);
});
}
track(label, object) {
const ref = new WeakRef(object);
this.trackedObjects.set(label, {
ref,
trackedAt: Date.now(),
type: object.constructor.name,
});
this.registry.register(object, label);
}
snapshot(label = "") {
if (performance.memory) {
this.snapshots.push({
label,
timestamp: Date.now(),
usedJSHeapSize: performance.memory.usedJSHeapSize,
totalJSHeapSize: performance.memory.totalJSHeapSize,
jsHeapSizeLimit: performance.memory.jsHeapSizeLimit,
});
}
}
getLeaks() {
const leaks = [];
for (const [label, entry] of this.trackedObjects) {
if (entry.ref.deref() !== undefined) {
const age = Date.now() - entry.trackedAt;
leaks.push({
label,
type: entry.type,
ageMs: age,
alive: true,
});
}
}
return leaks;
}
getMemoryTrend() {
if (this.snapshots.length < 2) return null;
const first = this.snapshots[0];
const last = this.snapshots[this.snapshots.length - 1];
const deltaBytes = last.usedJSHeapSize - first.usedJSHeapSize;
const deltaTime = last.timestamp - first.timestamp;
return {
growthBytes: deltaBytes,
growthMB: (deltaBytes / (1024 * 1024)).toFixed(2),
durationMs: deltaTime,
bytesPerSecond: Math.round((deltaBytes / deltaTime) * 1000),
snapshots: this.snapshots.length,
};
}
report() {
console.group("[LeakDetector] Report");
console.log("Tracked objects:", this.trackedObjects.size);
console.log("Potential leaks:", this.getLeaks());
console.log("Memory trend:", this.getMemoryTrend());
console.groupEnd();
}
}
// Usage
const detector = new LeakDetector();
detector.snapshot("start");
let widget = { name: "sidebar", data: new Array(10000) };
detector.track("sidebar-widget", widget);
// Simulate cleanup
widget = null;
setTimeout(() => {
detector.snapshot("after-cleanup");
detector.report();
}, 5000);Rune AI
Key Insights
- Event listeners are the top leak source: Always use
AbortControlleror named functions withremoveEventListenerto clean up listeners when components are destroyed - Closures capture entire scope: Null out large objects after extracting needed values, or use block scoping to limit what the closure retains
- Timers must be cleared explicitly: Store every
setIntervalandsetTimeoutID and clear them on cleanup; leaked timers retain their callback closures indefinitely - WeakRef enables GC-friendly caches: Use
WeakRefwithFinalizationRegistryfor caches that automatically release entries when the original objects are collected - Component lifecycle requires cleanup orchestration: Collect all cleanup functions during initialization and call them all during destruction to prevent accumulated leaks across SPA navigation
Frequently Asked Questions
How do I know if my application has a memory leak?
Can garbage collection free memory from closures?
Are WeakMap and WeakSet enough to prevent all leaks?
Do single-page applications leak more than multi-page apps?
Conclusion
Memory leaks in JavaScript stem from event listeners, closures, timers, detached DOM nodes, and observers retaining references that prevent garbage collection. Use AbortController for listener cleanup, null out large references in closures, clear all intervals, disconnect observers, and leverage WeakRef/FinalizationRegistry for cache-safe references. For understanding the GC internals, see JavaScript Garbage Collection: Complete Guide. For profiling tools, see JavaScript Profiling: Advanced Performance 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.