JavaScript Memory Leaks — The Reference That Won’t Die and How to Find It
Four common JavaScript memory leak patterns — event listeners, closures, detached DOM nodes, accidental globals — with before/after code and a Chrome DevTools heap snapshot walkthrough.
Your app worked flawlessly in testing. An hour into production the heap is at 400MB. Two hours in, it’s 900MB and the tab is stuttering. Not a slow API, not a missing index — a memory leak. The garbage collector can’t free what it doesn’t know is garbage, and something in your code is holding references longer than it should.
This covers the four patterns responsible for most JavaScript memory leaks in browser and Node.js apps, with concrete before/after code for each, plus a walkthrough of Chrome DevTools for finding the ones you didn’t know you had.
The four patterns you’ll write at least once
1. Event listeners that never come off
This is the dominant leak in single-page apps. You add a listener when a component initialises. The component is torn down — but nobody called removeEventListener. Next time the component mounts, another listener attaches. Do this on a route that’s visited dozens of times and you have dozens of handlers firing on every keypress, scroll, or resize.
// Leak — a new listener adds on every init(), nothing removes it
function init() {
document.addEventListener('keydown', handleKeyDown);
window.addEventListener('resize', handleResize);
}
function destroy() {
// placeholder that does nothing useful
}
// Fixed — symmetric add and remove; same function reference required
function init() {
document.addEventListener('keydown', handleKeyDown);
window.addEventListener('resize', handleResize);
}
function destroy() {
document.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('resize', handleResize);
}
The critical constraint: the function reference passed to removeEventListener must be identical to the one passed to addEventListener. Anonymous functions break this — () => {} !== () => {} — which is why you need to store the handler somewhere (module scope, class property, a Map keyed by target).
In frameworks, this is first-class: React’s useEffect return value, Vue’s onUnmounted, Angular’s ngOnDestroy. In vanilla JS it’s entirely on you.
If you want to audit existing code for unpaired listeners, the Testeur de regex can help — search for addEventListener calls and scan for matching removeEventListener in the same file using patterns like addEventListener\(['"]\w+['"].
2. Closures keeping large objects alive
Closures are not leaks — they’re a core language feature. They become leaks when a callback closes over a reference to something large, and that callback stays reachable (attached to a timer, an event emitter, a promise chain) long after the large object should have been freed.
// Leak — the setInterval callback closes over the entire `response` object.
// The object lives as long as the interval does.
function fetchAndSchedule() {
fetch('/api/large-dataset')
.then(res => res.json())
.then(response => {
setInterval(() => {
// response.meta.lastUpdated is all we need,
// but the whole response — potentially megabytes — is retained
syncWith(response.meta.lastUpdated);
}, 10_000);
});
}
// Fixed — extract the minimum data before the callback captures it.
// `response` can be GC'd once the .then() completes.
function fetchAndSchedule() {
fetch('/api/large-dataset')
.then(res => res.json())
.then(response => {
const lastUpdated = response.meta.lastUpdated;
setInterval(() => {
syncWith(lastUpdated);
}, 10_000);
});
}
The same pattern shows up in React class components where a stale closure captures this (and everything this holds), in Node.js streams where a callback retains the entire buffer, and in any async operation that captures a large input instead of deriving a small output from it upfront.
3. Detached DOM nodes
A detached DOM node is a subtree that’s been removed from the document but is still reachable from JavaScript. The GC sees an active reference and keeps the node — and its entire subtree — in memory.
// Leak — element is gone from the page but `panel` still points to it.
// If `panel` has 500 child nodes, they're all retained.
const container = document.getElementById('app');
let panel = document.createElement('div');
buildComplexUI(panel); // populates with many child nodes
container.appendChild(panel);
// Later — remove from page
container.removeChild(panel);
// `panel` is still in scope. The subtree is still in memory.
// Fixed — null the reference after removal
container.removeChild(panel);
panel = null;
This is especially common when you cache DOM references in module-level variables or class properties. The view renders, you stash a reference to a node for later, the view is destroyed — but the module-level variable survives. The entire subtree hangs off it.
WeakRef and FinalizationRegistry (available since Chrome 84 / Node 14.6) let you hold a reference without preventing GC, but they’re rarely necessary. Nulling the reference on teardown is almost always the right fix.
4. Accidental globals
In non-strict mode, assigning to an undeclared variable silently creates a property on window. Properties on window live for the lifetime of the page. This is especially deceptive because the leak looks like a local variable.
// Leak — `cache` is intended as local but creates window.cache
// because there's no var/let/const
function processItems(items) {
cache = {}; // silent global
items.forEach(item => {
cache[item.id] = expensiveTransform(item);
});
return Object.values(cache);
}
// Call this enough times and you accumulate increasingly large
// versions of `cache` on the window object.
// Fixed
'use strict'; // turns the silent global into a ReferenceError
function processItems(items) {
const cache = {}; // local
items.forEach(item => {
cache[item.id] = expensiveTransform(item);
});
return Object.values(cache);
}
ES modules are strict by default, so if you’re writing import/export you’re already protected. For non-module scripts, 'use strict' at the file top is the one-line change that prevents this entire category. A linter rule (no-undef in ESLint) also catches it before runtime.
Finding leaks with Chrome DevTools
Knowing the patterns is useful for audits. For finding an active leak in a running app, the Memory tab in Chrome DevTools is the actual tool.
The two-snapshot workflow
Open DevTools → Memory. For most leak hunts, Heap Snapshot is what you want. The workflow:
- Load the page to a stable state (initial data fetched, no pending operations)
- Take Snapshot 1 — click the camera icon
- Perform the suspected leak: navigate to the route and back, open/close the modal, run the operation 5 or 10 times
- Force a GC pass: click the trash icon (“Collect garbage”) in the Memory tab
- Take Snapshot 2
- In the Snapshot 2 view, change the dropdown from “All objects” to “Objects allocated between Snapshot 1 and Snapshot 2”
What you’re looking for: objects that appear in the delta that should have been cleaned up. Your own component classes, large arrays, anything labeled Detached HTMLElement.
The Detached filter
In any snapshot view, type Detached in the filter box. This immediately shows you every DOM node that’s been removed from the document but is still reachable from JS. A long list here is pattern 3 — orphaned references to removed nodes.
Click any Detached entry. The Retainers panel at the bottom shows you the full reference chain keeping it alive: a variable name, an event listener, a closure. That’s your leak. Follow the chain upward to find where the reference is being held, and that’s where you add the = null.
Shallow size vs. retained size
Two columns matter when reading snapshot data:
- Shallow size — memory occupied by the object’s own fields (not children)
- Retained size — memory that would be freed if this object were GC’d, including everything it transitively holds
Sort by retained size descending. A small object with a huge retained size is anchoring a large subgraph. That’s where the leak is. A large shallow size without large retained size usually just means it’s a big string or typed array — those are often fine.
Allocation timeline for hard cases
If snapshots aren’t isolating it, switch to Allocation instrumentation on timeline. Click start, reproduce the leak, stop the recording. The timeline shows you bars for allocations over time — a rising baseline that doesn’t flatten means something is accumulating. Click into the bars to see what was allocated. This is slower but often surfaces leaks that snapshots miss because they happen in short-lived intermediate objects that then anchor something longer-lived.
Node.js: the same leaks, running longer
Node processes run for hours or days without a page refresh to reset state. The same four patterns apply, but the consequences compound.
EventEmitter listener accumulation
Node’s EventEmitter warns when a single event has more than 10 listeners — the default max. This warning is a signal, not a hard limit. If you see it in production logs, something is adding listeners on a hot path without removing them.
// Leak — adds a SIGTERM listener on every request
app.get('/stream', (req, res) => {
process.on('SIGTERM', () => {
res.end('closed');
});
// After 1000 requests: 1000 SIGTERM handlers, all still alive
});
// Fixed — use once() for single-fire events, or clean up explicitly
app.get('/stream', (req, res) => {
const cleanup = () => res.end('closed');
process.once('SIGTERM', cleanup);
// Remove the handler when the response closes
res.on('close', () => {
process.removeListener('SIGTERM', cleanup);
});
});
Module-level caches without expiry
Caching at module scope is idiomatic in Node — it’s how you share state between requests. It becomes a leak when the cache grows unboundedly. A naive memoization of an API response or parsed file that’s keyed by user input will grow until the process OOMs.
// Leak — cache grows with every unique userId
const userCache = {};
async function getUser(userId) {
if (!userCache[userId]) {
userCache[userId] = await db.users.findById(userId);
}
return userCache[userId];
}
// Fixed — bounded cache; LRU is the standard pattern
import LRU from 'lru-cache';
const userCache = new LRU({ max: 500, ttl: 1000 * 60 * 5 }); // 500 entries, 5m TTL
async function getUser(userId) {
if (!userCache.has(userId)) {
userCache.set(userId, await db.users.findById(userId));
}
return userCache.get(userId);
}
Finding Node.js leaks with –inspect
Chrome DevTools works with Node too. Start the process with node --inspect app.js and navigate to chrome://inspect in Chrome. The same Memory tab, the same heap snapshot workflow, running against your server process.
For production profiling without stopping the server, the heapdump package lets you trigger a snapshot on demand (via a SIGUSR2 or an internal endpoint) and write it to disk for later analysis in DevTools.
Installez nos extensions
Ajoutez des outils IO à votre navigateur préféré pour un accès instantané et une recherche plus rapide
恵 Le Tableau de Bord Est Arrivé !
Tableau de Bord est une façon amusante de suivre vos jeux, toutes les données sont stockées dans votre navigateur. D'autres fonctionnalités arrivent bientôt !
Outils essentiels
Tout voir Nouveautés
Tout voirMise à jour: Notre dernier outil was added on Juin 26, 2026
