Memory leaks in JS
A memory leak is a situation where a program keeps references to objects it no longer needs, so the garbage collector cannot free them. In other words, an object is no longer used but stays in memory because of forgotten references, gradually eating up the heap.
Theory
TL;DR
- A leak is holding unnecessary objects in memory through references nobody cleared.
- The JS garbage collector only removes unreachable objects: if even one reference exists, the object is alive.
- Main causes: implicit globals, uncleared timers, listeners that were never removed, closures over large data, unbounded caches, references to removed DOM nodes.
- Symptoms: the heap grows from snapshot to snapshot, detached DOM nodes appear, the interface slows down over time.
- Diagnosis: Chrome DevTools, the Memory tab and a heap snapshot; in Node.js,
--inspect,heapdump,process.memoryUsage(). - Prevention: clean up timers and listeners, bound your caches, use
WeakMapandWeakSetwith their weak references.
Quick example
const btn = document.getElementById('btn');
function handleClick() {
console.log('clicked');
}
btn.addEventListener('click', handleClick);
btn.remove(); // the element is out of the DOM, but the listener stayed
// handleClick and btn itself remain in memory: this is a detached DOM nodeThe correct version: remove the listener with btn.removeEventListener('click', handleClick) before dropping the node, and clear any outer variables that point to the element.
How the garbage collector works and where a leak comes from
JavaScript manages memory automatically. The garbage collector periodically looks for reachable objects, that is, objects you can walk to through a chain of references from the roots (the global object, the call stack, live closures).
- Everything with no references to it (unreachable) is removed.
- Everything with at least one reference stays in memory.
That is exactly where the problem comes from: the object is logically no longer needed, but some reference to it survives, so the collector considers it alive and does not free it. Importantly, the engine cannot tell a needed reference from a forgotten one, so a leak is always a bug in the code, never in the collector.
Typical causes of leaks
| Cause | Example | What happens |
|---|---|---|
| Global variables | window.obj = { big: 'data' } | they live until the tab is closed |
| Uncleared timers and intervals | setInterval(() => {...}, 1000) without clearInterval | the callback holds its closure and is never released |
| Event listeners | element.addEventListener('click', handler) without removeEventListener | the element is gone, the handler stays in memory |
| Closures over large data | functions capturing a scope with large objects | the collector cannot free that scope |
| Caches and data structures | a Map that grows without evicting old keys | entries are never removed automatically |
| DOM references after removing a node | const el = document.getElementById('app'); el.remove(); | the node is out of the DOM, but el still holds it |
An implicit global:
function createLeak() {
leak = []; // without let / const the variable lands on window
for (let i = 0; i < 1000000; i++) leak.push(i);
}
createLeak();
// leak lives until the tab is closedA timer nobody stops:
function start() {
const data = new Array(1e6).fill('x');
setInterval(() => console.log(data.length), 1000);
}
start();
// data is never cleared, the callback closure holds itAn unbounded cache:
const cache = new Map();
function heavyCalc(key) {
if (cache.has(key)) return cache.get(key);
const result = Array(1e5).fill(key);
cache.set(key, result); // no entry is ever removed
return result;
}How to detect a memory leak
In the browser (Chrome DevTools):
- Open the Memory tab and pick Heap snapshot.
- Take a snapshot.
- Repeat the action you suspect (open and close a modal, navigate between pages) and take a second snapshot 10 to 30 seconds later.
- Compare the snapshots in Comparison mode: if the object count or the number of
Detached DOM nodeskeeps rising, that is a leak.
The Performance tab with the Memory checkbox enabled is useful too: it shows whether the heap size returns to its baseline after a collection, or whether the line keeps creeping upwards.
In Node.js:
node --inspectplus Chrome DevTools for a heap snapshot.- Tooling such as
clinic.js,node --inspect-brkand theheapdumppackage. - Periodic monitoring with
process.memoryUsage()and itsheapUsedandrssmetrics.
Symptoms you can see without a profiler:
- The app gradually slows down during a long session.
- Memory grows and never comes back down after navigation.
- Interface delays, especially during scrolling and rendering.
- Frequent and long garbage collection pauses.
- The tab eventually crashes with an out of memory message.
How to prevent leaks
| Problem | Solution |
|---|---|
| Global variables | Always declare with let and const, enable strict mode |
| Timers | Call clearTimeout and clearInterval when stopping |
| Event listeners | Detach with removeEventListener on unmount |
| Closures | Do not keep large objects in the scope of long lived functions |
| Caches | Bound the size and evict old entries, or use WeakMap / WeakSet |
| DOM | When removing a node, clear every variable referencing it |
| SPA applications | Watch the cleanup in useEffect, onUnmounted and similar hooks |
A separate tool is WeakMap and WeakSet. They hold weak references, so they do not block garbage collection:
const cache = new WeakMap();
function getData(obj) {
if (cache.has(obj)) return cache.get(obj);
const data = heavyCalculation(obj);
cache.set(obj, data);
return data;
}If obj is not used anywhere else, the collector will remove it automatically even though it is a key in the WeakMap. The price: such collections are not iterable, have no size, and only objects can be keys.
A short summary:
| What | Description |
|---|---|
| Memory leak | Holding unneeded objects in memory |
| Cause | References that are never released |
| Consequence | Growing memory, lag, a crashing tab |
| Diagnosis | Chrome DevTools, the Memory tab and heap snapshots |
| Fix | Clean up timers, listeners, caches and DOM references |
| Prevention | WeakMap, WeakSet, cleanup hooks |
Common mistakes
- Believing the collector counts references. Modern engines use reachability (mark and sweep), so cyclic references are not a leak by themselves: two structures pointing at each other but unreachable from outside will be collected.
- Removing a listener with an anonymous function.
removeEventListener('click', () => {...})does nothing: you need the exact same function reference you passed toaddEventListener. - Forgetting cleanup in components. A
useEffectwithout a cleanup function leaves subscriptions, timers andAbortControllerinstances alive after the component unmounts. - Confusing high memory usage with a leak. An app may simply hold a large legitimate state; a leak is when memory grows and does not come back after a collection.
- Treating
WeakMapas a universal fix. Only the key is weak; if the value points back at the key or at something large and reachable, nothing is freed. - Drawing conclusions from a single heap snapshot. A leak is only visible over time: you need at least two snapshots taken after the same scenario and a forced collection.
- Closing over an entire large object for the sake of one field. Copying out the value you need (
const { id } = user) lets the rest of the structure be collected.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.