How to Find and Fix JavaScript Memory Leaks
Learn how to detect, diagnose, and fix JavaScript memory leaks using Chrome DevTools heap snapshots, allocation timelines, and common leak patterns.
What you'll learn
- ✓The most common causes of memory leaks in JavaScript
- ✓How to use Chrome DevTools to find leaks
- ✓How to interpret heap snapshots and allocation timelines
- ✓Proven patterns to prevent leaks in production code
Prerequisites
- •JavaScript fundamentals
- •Basic understanding of garbage collection
Memory leaks are one of the most insidious bugs in JavaScript applications. They do not crash your app immediately. Instead, they slowly degrade performance until the tab becomes unresponsive or the Node.js process runs out of memory. Finding them requires understanding how JavaScript manages memory and knowing the right tools.
How JavaScript Memory Works
JavaScript uses automatic garbage collection. The garbage collector periodically identifies objects that are no longer reachable from the root (the global object, the current call stack, and active closures) and frees their memory. A memory leak happens when your code holds references to objects that are no longer needed, preventing the garbage collector from reclaiming them.
The key concept is reachability. If an object can be reached through any chain of references starting from the root, it stays in memory. A leak is just an unintended reference that keeps something alive.
The Five Most Common Leak Patterns
1. Forgotten Event Listeners
This is the single most common source of leaks in browser applications:
function setupHandler() {
const largeData = new Array(1_000_000).fill("x");
document.addEventListener("click", function handler() {
console.log(largeData.length);
});
}
setupHandler();
The handler closure captures largeData. Even if you never need largeData again, it stays in memory as long as the click listener is registered. With SPAs that mount and unmount components, this compounds quickly.
The fix: always remove listeners when they are no longer needed:
class Component {
#controller = new AbortController();
mount() {
document.addEventListener(
"click",
() => this.handleClick(),
{ signal: this.#controller.signal }
);
window.addEventListener(
"resize",
() => this.handleResize(),
{ signal: this.#controller.signal }
);
}
unmount() {
this.#controller.abort();
}
handleClick() { /* ... */ }
handleResize() { /* ... */ }
}
Using AbortController lets you remove all listeners with a single call. No need to keep references to individual handler functions.
2. Detached DOM Nodes
When you remove a DOM element but keep a JavaScript reference to it, the entire subtree stays in memory:
let cachedElement = null;
function cacheElement() {
cachedElement = document.getElementById("large-table");
}
function removeElement() {
document.getElementById("large-table")?.remove();
}
After calling both functions, the table has been removed from the DOM but cachedElement still references it. The entire table and all its children remain in memory.
The fix: null out references when removing elements:
function removeElement() {
document.getElementById("large-table")?.remove();
cachedElement = null;
}
Or use WeakRef if you need a non-preventing reference:
let cachedRef = null;
function cacheElement() {
const el = document.getElementById("large-table");
cachedRef = new WeakRef(el);
}
function getElement() {
return cachedRef?.deref();
}
3. Closures Capturing More Than Intended
Closures capture their entire lexical scope, not just the variables they reference. Some engines optimize this, but it is not guaranteed:
function createProcessor() {
const hugeConfig = loadHugeConfig();
const cache = new Map();
return {
process(key) {
if (cache.has(key)) return cache.get(key);
const result = expensiveCalculation(key);
cache.set(key, result);
return result;
},
clearCache() {
cache.clear();
},
};
}
The hugeConfig might be kept alive even if only cache is used. Restructure to limit what closures capture:
function createProcessor() {
const config = extractNeededFields(loadHugeConfig());
const cache = new Map();
return {
process(key) {
if (cache.has(key)) return cache.get(key);
const result = calculate(key, config);
cache.set(key, result);
return result;
},
clearCache() {
cache.clear();
},
};
}
4. Unbounded Caches and Collections
Maps, Sets, and arrays that grow without bound are a classic leak:
const requestLog = [];
function logRequest(req) {
requestLog.push({
url: req.url,
timestamp: Date.now(),
headers: { ...req.headers },
body: req.body,
});
}
In a Node.js server handling thousands of requests per second, this array will consume all available memory.
The fix: use bounded data structures:
class BoundedQueue {
#items = [];
#maxSize;
constructor(maxSize = 1000) {
this.#maxSize = maxSize;
}
push(item) {
this.#items.push(item);
if (this.#items.length > this.#maxSize) {
this.#items.shift();
}
}
getAll() {
return [...this.#items];
}
}
const requestLog = new BoundedQueue(5000);
Or use WeakMap when caching data associated with objects:
const metadataCache = new WeakMap();
function getMetadata(element) {
if (metadataCache.has(element)) {
return metadataCache.get(element);
}
const metadata = computeMetadata(element);
metadataCache.set(element, metadata);
return metadata;
}
When the element is garbage collected, its entry in the WeakMap is automatically cleaned up.
5. Forgotten Timers and Intervals
Intervals that are never cleared keep their callback and its closure alive:
function startPolling(url) {
const data = { results: [] };
setInterval(async () => {
const response = await fetch(url);
const json = await response.json();
data.results.push(json);
updateUI(data);
}, 5000);
}
The fix: always store and clear interval IDs:
function startPolling(url) {
const data = { results: [] };
let intervalId = null;
function start() {
intervalId = setInterval(async () => {
const response = await fetch(url);
const json = await response.json();
data.results.push(json);
updateUI(data);
}, 5000);
}
function stop() {
if (intervalId !== null) {
clearInterval(intervalId);
intervalId = null;
}
}
start();
return { start, stop };
}
Finding Leaks with Chrome DevTools
Heap Snapshots
The most powerful tool for finding leaks is the heap snapshot comparison:
- Open DevTools, go to the Memory tab
- Take a Heap snapshot (baseline)
- Perform the action you suspect is leaking (e.g., navigate to a page and back)
- Take another heap snapshot
- Select the second snapshot and change the view to Comparison
The comparison view shows objects that were allocated between the two snapshots and are still retained. Sort by Size Delta to find the biggest retainers.
The Three-Snapshot Technique
For more accurate results:
- Take snapshot 1 (baseline)
- Perform the suspect action
- Take snapshot 2
- Perform the same action again
- Take snapshot 3
- Compare snapshot 3 to snapshot 2
Objects that accumulate between snapshots 2 and 3 are very likely leaks, because one-time initialization costs are already captured in snapshot 2.
Allocation Timeline
For finding leaks in real time:
- In the Memory tab, select Allocation instrumentation on timeline
- Start recording
- Perform the actions you suspect are leaking
- Stop recording
Blue bars that remain (never become gray) represent allocations that were never garbage collected. Click on them to see what object was allocated and trace its retention path.
Finding Leaks in Node.js
In Node.js, you can take heap snapshots programmatically:
const v8 = require("v8");
function takeHeapSnapshot() {
const filename = `heap-${Date.now()}.heapsnapshot`;
const snapshotPath = v8.writeHeapSnapshot(filename);
console.log(`Heap snapshot written to ${snapshotPath}`);
}
process.on("SIGUSR2", () => {
takeHeapSnapshot();
});
Send SIGUSR2 to the process to capture a snapshot, then load it in Chrome DevTools for analysis.
For monitoring memory over time:
function logMemoryUsage() {
const usage = process.memoryUsage();
console.log({
rss: `${(usage.rss / 1024 / 1024).toFixed(1)} MB`,
heapUsed: `${(usage.heapUsed / 1024 / 1024).toFixed(1)} MB`,
heapTotal: `${(usage.heapTotal / 1024 / 1024).toFixed(1)} MB`,
external: `${(usage.external / 1024 / 1024).toFixed(1)} MB`,
});
}
setInterval(logMemoryUsage, 10000);
If heapUsed grows steadily over time without leveling off, you have a leak.
Automated Leak Detection
You can build automated tests to catch memory leaks:
async function checkForLeaks(action, iterations = 10) {
if (global.gc) global.gc();
const before = process.memoryUsage().heapUsed;
for (let i = 0; i < iterations; i++) {
await action();
}
if (global.gc) global.gc();
const after = process.memoryUsage().heapUsed;
const growth = after - before;
const perIteration = growth / iterations;
console.log({
totalGrowth: `${(growth / 1024).toFixed(1)} KB`,
perIteration: `${(perIteration / 1024).toFixed(1)} KB`,
});
return growth;
}
Run Node.js with --expose-gc to enable manual garbage collection for accurate measurements.
Prevention Patterns
Use WeakMap and WeakSet for Object-Keyed Caches
const computedStyles = new WeakMap();
function getCachedStyle(element) {
if (!computedStyles.has(element)) {
computedStyles.set(element, window.getComputedStyle(element));
}
return computedStyles.get(element);
}
Use FinalizationRegistry for Cleanup
const registry = new FinalizationRegistry(resourceId => {
console.log(`Cleaning up resource: ${resourceId}`);
releaseExternalResource(resourceId);
});
function createResource() {
const resource = allocateExternalResource();
const wrapper = { use() { return resource.data; } };
registry.register(wrapper, resource.id);
return wrapper;
}
Scope Variables Tightly
function processData(items) {
const result = [];
for (const item of items) {
const transformed = transform(item);
result.push(transformed);
}
return result;
}
Block-scoped const and let declarations inside loops are released as soon as the block exits, unlike var which is function-scoped.
Wrapping Up
Memory leaks in JavaScript come down to unintended references that prevent garbage collection. The five most common sources are forgotten event listeners, detached DOM nodes, excessive closure captures, unbounded caches, and forgotten timers. Chrome DevTools heap snapshots and the three-snapshot comparison technique are your primary diagnostic tools. On the prevention side, use AbortController for listener cleanup, WeakMap/WeakSet for object-keyed caches, and always clear intervals and timeouts when they are no longer needed.
Related articles
- Node.js Debugging Node.js Memory Leaks
Find and fix memory leaks in Node.js using heap snapshots, sampling, and a few reliable patterns to avoid leaks.
- JavaScript JavaScript Event Loop Explained: How Async Code Really Works
Understand how the JavaScript event loop handles async operations including the call stack, microtasks, macrotasks, and execution order with practical examples.
- JavaScript JavaScript Streams API: Process Data Incrementally
Master the JavaScript Streams API to process large files, network responses, and data pipelines incrementally without loading everything into memory.
- JavaScript JavaScript Web Workers: Run Code Off the Main Thread
Learn how to use JavaScript Web Workers to run CPU-intensive code off the main thread, keeping your UI responsive with practical examples and patterns.