Memory Leak Patterns and Causes
What Makes a Memory Leak
A memory leak is not a missing free() call — JavaScript doesn't have one. A memory leak is an unintentional retention: an object stays reachable (and therefore alive) when your code no longer needs it.
And here's what makes it tricky: the GC is doing its job perfectly. It keeps alive everything reachable from a root. The problem is that something you forgot about is keeping a reference chain alive that connects a root to data you thought was gone.
Memory leaks are insidious. They don't crash your app immediately. They accumulate quietly: 50KB per user interaction, 200KB per route change, 1MB per minute. After 30 minutes, the tab uses 500MB. After an hour, the browser kills it. Your user blames your app for being "slow" and never tells you why.
There are five classic patterns. Learn them, and you'll catch 90% of leaks before they ship.
Leak 1: Forgotten Event Listeners
This is the most common leak in frontend applications, bar none. Every addEventListener creates a GC root — the callback and its entire closure are kept alive as long as the listener is registered.
// LEAK: Listener is never removed
class Tooltip {
constructor(element) {
this.element = element;
this.data = fetchExpensiveData(); // 5MB of computed data
document.addEventListener('scroll', () => {
this.updatePosition();
});
}
destroy() {
this.element.remove();
// Forgot to remove the scroll listener!
// The closure still references 'this' → Tooltip instance → this.data (5MB)
// GC root: document → scroll listener → closure → Tooltip → data
}
}
// User opens and closes tooltips...
// Each destroyed Tooltip leaks 5MB because the listener keeps it alive
The Fix: Always Remove What You Register
class Tooltip {
constructor(element) {
this.element = element;
this.data = fetchExpensiveData();
// Store a reference so we can remove the exact same function
this.handleScroll = () => this.updatePosition();
document.addEventListener('scroll', this.handleScroll);
}
destroy() {
this.element.remove();
document.removeEventListener('scroll', this.handleScroll);
// Now the listener is removed → closure is unreachable → Tooltip can be collected
}
}
The Modern Fix: AbortController
class Tooltip {
constructor(element) {
this.element = element;
this.data = fetchExpensiveData();
this.controller = new AbortController();
document.addEventListener('scroll', () => this.updatePosition(), {
signal: this.controller.signal
});
window.addEventListener('resize', () => this.updatePosition(), {
signal: this.controller.signal
});
// Can register many listeners with the same signal
}
destroy() {
this.element.remove();
this.controller.abort(); // removes ALL listeners registered with this signal
}
}
Leak 2: Detached DOM Trees
This one is sneaky. When you remove a DOM element from the document but keep a JavaScript reference to it, the element (and its entire subtree) stays in memory. This is a detached DOM tree — it exists in the heap but has no visual representation.
// LEAK: Detached DOM nodes
let cachedElements = [];
function createListItem(text) {
const li = document.createElement('li');
li.textContent = text;
cachedElements.push(li); // caching for "performance"
return li;
}
function clearList() {
const list = document.getElementById('list');
list.innerHTML = ''; // removes from DOM
// But cachedElements still holds references to every <li>!
// Each <li> is a detached DOM node — in memory, not in document
}
// After clearing and rebuilding the list 100 times,
// cachedElements holds 100x the elements
The Fix: Clear References When Removing DOM Nodes
function clearList() {
const list = document.getElementById('list');
list.innerHTML = '';
cachedElements.length = 0; // release all references
}
// Or better: don't cache DOM elements at all
// Rely on the DOM as the source of truth and query when needed
How to find detached DOM trees in DevTools
- Open Chrome DevTools → Memory → Take Heap Snapshot
- In the filter dropdown, select "Detached" or search for "Detached"
- Look for "Detached HTMLDivElement", "Detached HTMLLIElement", etc.
- Click on one and look at the Retainers panel to see what's keeping it alive
- The retaining path shows exactly which variable or closure holds the reference
You can also use the getEventListeners(element) function in the DevTools Console to check if removed elements still have listeners attached.
Leak 3: Closures Over Large Scopes
You learned about closures and Context objects in the stack/heap topic. Now let's see how they cause leaks. When a closure captures a variable from its outer scope, V8 creates a Context object that keeps the captured variables alive. If the closure captures more than it needs — or if V8 cannot split the Context — large amounts of data get retained unintentionally.
// LEAK: Closure retains large scope
function processData() {
const rawData = new Array(1_000_000).fill({ x: 1, y: 2 }); // ~32MB
const summary = rawData.reduce((acc, item) => acc + item.x, 0);
// This closure only needs 'summary', but 'rawData' is in the same scope
return function getSummary() {
return summary;
};
}
const getter = processData();
// rawData might be retained if V8 puts both variables in the same Context
// Even though getSummary() never references rawData
The Fix: Isolate What the Closure Needs
function processData() {
const summary = computeSummary();
// rawData is now unreachable — it only existed inside computeSummary
return function getSummary() {
return summary;
};
}
function computeSummary() {
const rawData = new Array(1_000_000).fill({ x: 1, y: 2 });
return rawData.reduce((acc, item) => acc + item.x, 0);
}
const getter = processData();
// rawData was collected when computeSummary returned
// getSummary's closure only retains 'summary' (a number)
V8 is generally good at analyzing which variables a closure actually uses and only including those in the Context. But there are edge cases where it captures more: when eval() is present in the scope (forces all variables to be captured), when with statements are used, or when multiple closures in the same scope capture different variables but share a single Context object. Never use eval() in production code — it defeats most compiler optimizations.
Leak 4: Unbounded Collections
These are the silent killers. Maps, Sets, arrays, and objects that grow without bound. They're technically not "leaks" — every entry is intentionally added. But if entries are never removed, memory grows linearly with time or usage. And nobody notices until the tab crashes.
// LEAK: Cache that never evicts
const cache = new Map();
async function fetchUser(id) {
if (cache.has(id)) return cache.get(id);
const user = await fetch(`/api/users/${id}`).then(r => r.json());
cache.set(id, user); // cache grows forever
return user;
}
// After fetching 10,000 unique users, the cache holds all 10,000
// Even if most will never be accessed again
The Fix: Bound Your Collections
// LRU cache with a maximum size
class LRUCache {
constructor(maxSize = 100) {
this.maxSize = maxSize;
this.cache = new Map();
}
get(key) {
if (!this.cache.has(key)) return undefined;
// Move to end (most recently used)
const value = this.cache.get(key);
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
set(key, value) {
if (this.cache.has(key)) this.cache.delete(key);
this.cache.set(key, value);
// Evict oldest entry if over limit
if (this.cache.size > this.maxSize) {
const oldest = this.cache.keys().next().value;
this.cache.delete(oldest);
}
}
}
// Or use WeakMap if keys are objects (entries auto-remove when key is GC'd)
const weakCache = new WeakMap();
Leak 5: Forgotten Timers
setInterval callbacks are GC roots. If you never clearInterval, the callback runs forever, and its closure stays alive forever.
// LEAK: Interval is never cleared
function startAutoSave(editor) {
const state = editor.getState(); // large state object
setInterval(() => {
// This closure keeps 'state' alive
// Even if the editor is destroyed, the interval still runs
saveToServer(state);
}, 30_000);
}
// User navigates away from the editor page (SPA route change)
// The interval keeps running. state keeps growing if it's mutable.
// After 10 route changes, you have 10 intervals saving stale data.
The Fix: Clear Timers on Cleanup
function startAutoSave(editor) {
const state = editor.getState();
const intervalId = setInterval(() => {
saveToServer(state);
}, 30_000);
// Return cleanup function
return () => clearInterval(intervalId);
}
// In React:
useEffect(() => {
const cleanup = startAutoSave(editorRef.current);
return cleanup; // runs on unmount
}, []);
The Leak Detection Workflow
Alright, you know the patterns. Now how do you actually find them? When you suspect a memory leak, follow this systematic approach in Chrome DevTools:
- Reproduce the suspected leak — perform the action (open/close modal, navigate routes, add/remove items) multiple times
- Force GC — click the trash can icon in DevTools Memory panel before each snapshot
- Take Heap Snapshot #1 — baseline
- Perform the action that you suspect leaks
- Force GC again — ensure only truly retained objects remain
- Take Heap Snapshot #2 — after the suspected leak
- Compare snapshots — use "Comparison" view to see objects allocated between #1 and #2 that weren't freed
- Inspect retainers — for any unexpected survivors, check the retaining path to find what's keeping them alive
| What developers do | What they should do |
|---|---|
| Adding event listeners without storing a reference for removal Anonymous arrow functions can't be removed — you can't pass the same reference to removeEventListener | Use named functions or AbortController for easy cleanup |
| Caching DOM element references across component lifecycles Removed DOM nodes + cached references = detached DOM trees that leak | Query the DOM when needed, or clear caches on unmount |
| Creating closures in the same scope as large temporaries The closure may capture the entire scope Context, retaining data it never uses | Move heavy computation into separate functions that return only the needed result |
| Using unbounded Map/Set/Array as caches Collections that only grow and never shrink will eventually exhaust memory | Use LRU eviction, WeakMap for object keys, or set a maximum size |
| Forgetting to clear setInterval on component unmount Intervals are GC roots — they keep running and keep their closures alive forever | Always store the interval ID and clear it in cleanup (useEffect return, destroy method) |
- 1Every addEventListener needs a corresponding removeEventListener or AbortController.abort(). No exceptions.
- 2Removing a DOM element from the document doesn't free it — null out all JavaScript references to it too.
- 3Never create closures in the same scope as large temporaries. Isolate heavy work in separate functions that return only the result.
- 4Every collection (Map, Set, Array, Object used as cache) must have a bounded growth strategy: max size, LRU eviction, or WeakMap.
- 5Every setInterval must have a corresponding clearInterval in the cleanup path. Same for setTimeout in loops.
- 6When debugging leaks: take heap snapshots, compare them, and trace retaining paths. The retainer chain always reveals the root cause.