Free lesson · JavaScript Basic to Advance
Memory Leaks in Practice
When Memory Grows Forever
A memory leak in JavaScript is when objects you no longer need stay reachable from somewhere, so the garbage collector can't reclaim them. Over time, memory usage grows. In a long-running process — a Node server, a single-page application running for hours — leaks eventually exhaust available memory and crash the process.
// Classic leak pattern
const cache = {};
function rememberRequest(req) {
cache[req.id] = req; // never cleared — accumulates forever
}
This cache grows for every request. Nothing removes entries. Memory usage climbs steadily until the process either runs out of memory or gets killed by the OS. The fix here is obvious — add eviction logic. But many leaks are subtler, and identifying them is one of the most-asked Node and frontend performance questions.
This Topic covers common leak patterns, how they happen, and how to find them.
Interactive animation: The usual suspects: accidental globals, listeners and timers never removed, unbounded caches and detached DOM nodes.
Pattern 1: Unbounded Caches
The most common leak in server code. Any data structure that grows without an eviction policy is a leak waiting to happen.
const requestCache = {};
function handleRequest(req) {
if (requestCache[req.url]) {
return requestCache[req.url];
}
const result = expensiveComputation(req);
requestCache[req.url] = result;
return result;
}
Every unique URL adds a cache entry. Forever. The cache grows linearly with the number of distinct URLs the server has ever seen.
Fixes:
- LRU eviction. Cap the cache size, evict least-recently-used entries when full.
- TTL. Each entry has an expiration time. Periodically remove expired entries.
- WeakMap or WeakRef. If the cache keys are objects, use weak references.
- Don't cache at all. Sometimes the right answer. Caching adds complexity. Verify the cache earns its cost.
In production code, I default to using a real LRU library (like lru-cache on npm) rather than rolling my own. Caches are deceptively hard to get right — eviction policies, size accounting, concurrent access — and using a battle-tested library is usually correct.
Pattern 2: Forgotten Event Listeners
In frontend code, the most common leak source. Adding event listeners without removing them creates accumulating handlers.
class Widget {
constructor() {
this.handler = this.handleClick.bind(this);
document.addEventListener("click", this.handler);
}
handleClick() { /* ... */ }
destroy() {
// ⚠️ Did you remember to remove the listener?
}
}
If you create and "destroy" Widget instances many times without removing listeners, each one leaks. The document keeps references to all the bound handlers. The handlers keep references to the widget instances. Memory grows.
The fix:
class Widget {
constructor() {
this.handler = this.handleClick.bind(this);
document.addEventListener("click", this.handler);
}
handleClick() { /* ... */ }
destroy() {
document.removeEventListener("click", this.handler); // remove cleanup
}
}
Even better, the modern pattern using AbortController:
class Widget {
constructor() {
this.controller = new AbortController();
document.addEventListener("click", () => this.handleClick(), {
signal: this.controller.signal
});
}
handleClick() { /* ... */ }
destroy() {
this.controller.abort(); // removes all listeners with this signal
}
}
AbortController (Topic 12.5) makes cleanup harder to forget. A single abort() call removes all listeners registered with that signal.
In React, the equivalent is the cleanup function returned from useEffect. In Vue, it's onUnmounted. Each framework has its mechanism. Use them consistently.
Pattern 3: Detached DOM Nodes
A subtle frontend leak. You remove a DOM element from the document, but a JavaScript reference to it still exists somewhere.
const cache = [];
function temporaryThing() {
const el = document.createElement("div");
document.body.appendChild(el);
cache.push(el); // ⚠️ keeping a reference
setTimeout(() => {
el.remove();
// el is removed from the DOM but still in 'cache'
// V8 keeps the entire detached DOM subtree alive because of the reference
}, 1000);
}
The DOM tree being referenced isn't visible on the page anymore, but it's still in memory. If el had many children, all of them are still in memory too.
Detached DOM leaks are hard to spot in source code review. Memory profiling tools (covered in Topic 13.6) are the way to find them. Browser DevTools' Memory panel has a "Detached DOM nodes" category that specifically surfaces this pattern.
Interactive animation: The ghost table: each render removes old rows from the page, but a cache array keeps every one alive.
Pattern 4: Closures Holding References
We covered closures in Module 6. They keep references to their enclosing scope's variables. Sometimes those variables are larger than expected.
function setupEventHandler() {
const massiveData = loadBigData(); // 50MB of data
const computedSummary = summarize(massiveData);
return function handler() {
console.log(computedSummary);
// 'handler' closes over both 'massiveData' AND 'computedSummary'
// 'massiveData' stays alive even though only 'computedSummary' is used
};
}
document.addEventListener("click", setupEventHandler());
The handler only uses computedSummary, but the closure keeps massiveData alive too. The 50MB stays in memory for as long as the event listener exists.
The fix: scope the data narrowly. Don't keep references alive longer than necessary.
function setupEventHandler() {
const computedSummary = (() => {
const massiveData = loadBigData();
return summarize(massiveData);
// massiveData goes out of scope here
})();
return function handler() {
console.log(computedSummary);
};
}
function loadBigData() {
return new Array(1000000).fill(0);
}
function summarize(data) {
return data.length;
}
Now massiveData is contained inside the IIFE. Once the IIFE returns the summary, massiveData is no longer referenced and can be collected. The closure over handler only captures computedSummary.
Pattern 5: Timers and Intervals
setInterval and setTimeout callbacks keep references to anything in their closures. Forgetting to clear them produces leaks.
class Polling {
constructor() {
this.data = new Array(1000000); // big data
this.id = setInterval(() => {
console.log(this.data.length); // closure keeps 'this' alive
}, 1000);
}
// No clearInterval anywhere
}
const p = new Polling();
// 'p' might go out of scope...
// ...but the interval keeps running forever
// ...and 'this.data' stays in memory forever
Even if p goes out of scope in user code, the interval callback's closure still references it. The interval keeps the entire instance alive.
The fix: always pair setInterval with clearInterval. In React, this means cleanup in useEffect. In Node, this often means clearing intervals on shutdown signals.
Pattern 6: Global Variables
Global variables live forever. Anything they reference lives forever.
// Bad: assigning to a global accidentally (no `let`/`const`/`var`)
function setupApp() {
hugeArray = new Array(10_000_000); // global! never collected
}
// Bad: deliberate but unbounded
window.history = [];
function recordAction(action) {
window.history.push({ action, timestamp: Date.now() });
// Forever-growing history
}
Strict mode (Module 10) catches accidental globals — assignment to an undeclared variable throws. Use strict mode universally.
For deliberate globals that grow, apply the same advice as caches: bound them. An "infinite history" is usually a leak in disguise.
Finding Leaks in Practice
Detecting leaks requires profiling. In Chrome DevTools, the Memory panel offers:
Heap snapshots. Capture the entire heap at a moment. Compare two snapshots taken at different times to see what grew.
Allocation profiling. Record allocation activity over time, showing where memory comes from.
Allocation sampling. Sample-based profiling, lower overhead than full recording.
The standard workflow for finding a leak:
- Start your app.
- Take a heap snapshot. Call it baseline.
- Trigger the suspected leaking action (open a modal, navigate between pages, run a task).
- Force GC if available, then take another snapshot.
- Compare the snapshots. Look for object types that grew significantly.
- Drill into the retained objects to see what's holding them alive.
The "retainers" view shows the reference chain that's keeping each suspected leak alive. This chain points to the actual leak source — the variable, the closure, the event listener that should have been removed.
In Node.js, the node --inspect flag opens DevTools-like profiling for server code. The heap snapshot approach works the same way. For production, tools like heapdump let you trigger snapshots remotely.
At Netflix, we run continuous memory profiling on our Node services and alert on memory growth patterns. A service with stable memory usage is healthy. A service whose memory grows over time has a leak that will eventually crash it.
Interactive animation: The two readings that name the leak, ending in the retainers chain that shows what keeps it alive.
Common Pitfalls
Warning: The leak-finding traps.
Confusing high memory with leaks. A program that uses 500MB might be efficient (caching valuable data) or it might be leaking. Memory level alone doesn't indicate a leak. The trend over time does.
Triggering only one snapshot. Without a comparison, you can't tell what grew. Always take baseline and post-action snapshots.
Ignoring detached DOM nodes. Easy to miss because they're invisible. Browser DevTools surfaces this category specifically — check it during memory investigations.
Adding timers/listeners without thinking about cleanup. Every
setIntervalandaddEventListeneris a potential leak. Make cleanup part of the same code block where you create them.
Why This Matters in Interviews
Interview: Memory leak questions come up in senior and staff frontend and Node interviews. "Describe a memory leak you've debugged" is a common ask — and a real story is much more compelling than a theoretical one. The leak patterns above are all common enough that most experienced engineers have hit at least one.
Topic 13.7 covers the interview treatment.
Try It
Exercise: In a Node script, deliberately create a leak and observe it:
const cache = []; setInterval(() => { cache.push(new Array(1_000_000).fill(0)); console.log("Cache size:", cache.length); console.log("Heap used:", (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(2), "MB"); }, 100);Watch heap usage grow until the process dies. Then fix the leak by capping the cache at some size (say, 10 items) — use
if (cache.length > 10) cache.shift()before pushing. Re-run. Memory should stabilize.Seeing memory grow and then stabilize in real time makes the abstract concept concrete. Once you've watched a leak grow, you'll spot the patterns in real code.