Dev
Hunting Memory Leaks in JavaScript: Notes on V8 Mechanics & Chrome DevTools
Dev.toUnited States · NORTH AMERICA
JavaScript developers rarely have to think about manual memory management. Under the hood, the engine's Garbage Collector (GC) cleans up after us. However, automatic memory management is not foolproof...
JavaScript developers rarely have to think about manual memory management. Under the hood, the engine's Garbage Collector (GC) cleans up after us.
However, automatic memory management is not foolproof. Memory leaks in modern web apps usually aren't about forgotten allocations—they're about references that remain reachable longer than intended.
In this post, we’ll explore how V8 manages memory under the hood, walk through 7 common JavaScript memory-leak patterns, and show how to use Chrome DevTools Heap Snapshots to track them down.
1. How Garbage Collection Works: Reachability & V8 Heap
Modern JavaScript engines manage memory based on the concept of Reachability: an object is kept alive as long as it can be traced back to a GC Root (such as the global window object, current call stack execution contexts, or active host handles).
The V8 Generational Heap
V8 categorizes objects by age to optimize garbage collection performance:
- Young Generation (Scavenger): Short-lived allocations (most objects die young). Fast, frequent GC cycles clean these up using parallel copying algorithms.
- Old Generation (Major GC): Objects that survive multiple young-generation sweeps are promoted to the Old Space. Here, V8 uses a combination of Mark-Sweep-Compact alongside concurrent and incremental marking to prevent stop-the-world pauses.
Regardless of which generation an object lives in, Reachability is the deciding factor: if an application retains an unwanted reference path from a GC Root to an object, V8 cannot sweep it.
2. Common JavaScript Memory Leaks
Quick Reference:
| No. | Leak Pattern | Root Cause | Quick Fix |
|---|---|---|---|
| 1 | Global Scope Bleed | Undeclared variables (userCache = ...) |
Always use const / let
|
| 2 | Detached DOM | Removed element retained in JS array/object | Remove long-lived array/cache reference |
| 3 | Global Listeners |
window.addEventListener left unbound |
Call removeEventListener on cleanup |
| 4 | Forgotten Timers |
setInterval running in background |
Call clearInterval on teardown |
| 5 | Stale Closures | Long-lived closure captures large object | Avoid capturing unnecessary data |
| 6 | Unbounded Caches | Standard Map/Set storing object keys |
Evict entries or use weak collections |
| 7 | Console Logging |
console.log() holding objects in DevTools |
Clear logs or disable in production |
Case 1: Accidental Global Variables & Scope Bleed
In classic non-strict browser scripts, assigning to an undeclared variable can create a property on the global object, such as window, preventing GC from sweeping them even after the executing function ends.
// ❌ LEAKY CODE:
function processUserData(data) {
// Missing 'const/let' creates window.userCache
userCache = new Array(1_000_000).fill('Data');
}
// ✅ FIXED CODE:
function processUserDataCleanly(data) {
const userCache = new Array(1_000_000).fill('Data');
// After the function returns, this reference is no longer held
// by the local scope and the array can become collectible.
}
Case 2: Detached DOM Trees
A detached DOM node occurs when an element is removed from the DOM using .removeChild() or .remove(), but a JS object or cache retains a reference to it—or to one of its children.
const nodeCache = [];
document.getElementById('leak-btn').addEventListener('click', () => {
const listItem = document.getElementById('item-42');
// Attaching a huge payload directly to the element so it stands out in DevTools!
listItem.hugePayload = new Array(1_000_000).fill('💥 LEAKED DATA 💥');
listItem.parentNode.removeChild(listItem);
nodeCache.push(listItem);
console.log('Leaked item with 1M array elements!');
});
Figure 1: Profiling the detached element in Chrome DevTools. While its shallow size is only 0.1 kB, its retained size consumes 74% of the total heap memory due to the attached array.
Case 3: Dangling Global Event Listeners
Attaching event listeners to long-lived objects (window, document, or global event emitters) without explicit unbinding can retain the callback and any objects it captures for as long as the listener remains registered.
// ❌ LEAKY CODE:
function attachTracking() {
const heavyMetadata = new Array(500_000).fill('📊');
window.addEventListener('resize', () => {
console.log('Resized:', heavyMetadata.length);
});
}
// Even if the UI component relying on tracking is destroyed, window retains the callback.
// ✅ FIXED CODE:
function attachTrackingCleanly() {
const heavyMetadata = new Array(500_000).fill('📊');
const handleResize = () => console.log('Resized:', heavyMetadata.length);
window.addEventListener('resize', handleResize);
// Provide clear cleanup handler
return () => window.removeEventListener('resize', handleResize);
}
Case 4: Forgotten Timers & Unbound Animation Loops
Callback functions passed to setInterval remain referenced by the browser's background timer infrastructure until explicitly cleared with clearInterval().
Similarly, recursive requestAnimationFrame loops that continually schedule another frame will keep running until they stop scheduling frames or are explicitly cancelled with cancelAnimationFrame().
// ❌ LEAKY CODE:
function startSync(serverData) {
setInterval(() => {
// The active timer keeps the callback reachable,
// which can keep serverData reachable as well.
console.log('Pinging server with ID:', serverData.id);
}, 1000);
}
// ✅ FIXED: Return cleanup function to be called on teardown
function startSyncCleanly(serverData) {
const timerId = setInterval(() => {
console.log('Pinging server with ID:', serverData.id);
}, 1000);
// Return teardown function for when component/page unmounts
return function stopSync() {
clearInterval(timerId);
};
}
// Usage:
const stop = startSyncCleanly({ id: 42 });
// ... later when UI unmounts or user navigates away:
stop();
Case 5: Stale Closures
A long-lived closure can keep captured objects reachable for as long as the closure itself remains alive.
// ❌ LEAKY CODE:
let globalRunner;
function createRunner() {
const giantPayload = new Array(1_000_000).fill('🚀');
globalRunner = function() {
console.log(giantPayload.length);
};
}
createRunner();
// globalRunner remains reachable,
// so giantPayload can remain reachable too.
Here the retention path is straightforward:
Global scope
↓
globalRunner
↓
closure
↓
giantPayload
The fix is to avoid capturing large objects when the callback only needs a small piece of data:
// ✅ FIXED CODE:
let globalRunner;
function createRunner() {
const giantPayload = new Array(1_000_000).fill('🚀');
const payloadSize = giantPayload.length;
globalRunner = function() {
console.log(payloadSize);
};
}
Now the long-lived closure only needs payloadSize, not the large array.
Case 6: Unbounded Caches (Map/Set vs. WeakMap)
Standard Map and Set collections hold strong references to the objects they contain. This means as long as the Map itself is reachable, the objects it strongly references through its keys and values remain reachable too.
For general lookups and static data, Map is completely fine. However, using a global Map as an unbounded cache for temporary object metadata (like tracking active user sessions or attaching state to DOM nodes) will cause a memory leak unless you manually call .delete().
// ❌ LEAKY CODE:
const userSessionCache = new Map();
function trackUser(userObject) {
userSessionCache.set(userObject, { loginTime: Date.now() });
}
// Even if userObject is no longer needed, Map retains it as a key.
// ✅ FIXED CODE: Use WeakMap for object keys
const userSessionCacheClean = new WeakMap();
function trackUserCleanly(userObject) {
// WeakMap allows keys to be GC'd when no other references exist
userSessionCacheClean.set(userObject, { loginTime: Date.now() });
}
When to swap
MapforWeakMap:
- Use
Mapwhen keys are primitives (string,number) or when you need to iterate over entries (.forEach(),.keys()).- Use
WeakMapwhen keys are objects (like DOM elements or session instances) and you want their cached metadata to become eligible for garbage collection when no other references to the key remain.
Case 7: Chrome DevTools Console Log Retention
Chrome DevTools maintains internal references to objects logged to the console to enable interactive object inspection in the UI. While inspecting objects during active debugging sessions is harmless, logging massive data structures during performance profiling can artificially inflate heap memory metrics.
// ❌ POTENTIAL RETENTION DURING TEST RUNS:
function evaluateCalculation() {
const massiveMatrix = new Array(5_000_000).fill(1.0);
// DevTools may retain logged objects in memory during debugging
console.log('Matrix result:', massiveMatrix);
}
3. Systematic Profiling with Chrome DevTools
Step 1: Detect Leak Signals in the Performance Panel
- Open DevTools -> Performance.
- Check Memory.
- Click Record, perform suspect user actions, and click the Collect Garbage (trash icon) button.
- An ascending JS Heap baseline after forced GC is a strong signal that objects are remaining reachable and warrants investigation.

Figure 2: Performance panel timeline recording illustrating the classic "stair-step" JS Heap pattern. Repeated user interactions drive memory consumption up, and forcing Garbage Collection fails to bring the baseline down.
Step 2: Trace Retainers with Heap Snapshots
- Go to Memory panel -> Select Heap Snapshot.
- Take Snapshot 1 (baseline).
- Perform the suspect user interaction multiple times.
- Take Snapshot 2.
- Switch the perspective dropdown from Summary to Objects allocated between Snapshot 1 and Snapshot 2. (Tip: DevTools also provides dedicated preset filters like "Objects retained by detached nodes" in the Summary view to pinpoint DOM leaks instantly).

Figure 3: Inspecting the Retainers pane in Snapshot 2. Tracing the reference path exposes globalLeakedCache in the script scope, revealing the reference that keeps the large array reachable.
Conclusion
When debugging a JavaScript memory leak, always trace the reference path by asking:
- What object is being retained?
- What GC root is keeping it reachable?
- Where in the lifecycle should that reference have been released?
Preventing leaks comes down to explicit lifecycle management: clearing timers, removing event listeners, releasing unnecessary references, and leveraging weak collections like WeakMap and WeakSet.
