Closures, Garbage Collection and Leaks
Mental model: Memory is freed when nothing can reach it any more. A closure is something that can reach.
Level: advanced · about 14 minutes
JavaScript frees memory automatically, but "automatically" does not mean "always". The rule is reachability: if a value can still be reached from something alive, it stays. Closures are excellent at keeping things reachable, which is normally a feature and occasionally a bug.
roots (globals, current call stack, live event handlers)
│
├─► component ──► closure ──► captured scope ──► bigArray (ALIVE)
│
└─► cache Map ──► key ──► detached DOM node (ALIVE — leak!)
orphanedObject (collectable)
Leak 1 — the forgotten timer
❌ Runs forever
function start() {
const rows = new Array(1e6).fill('data');
setInterval(() => {
console.log(rows.length);
}, 1000);
}
// rows can never be collected
✅ Cleaned up
function start() {
const rows = new Array(1e6).fill('data');
const id = setInterval(() => {
console.log(rows.length);
}, 1000);
return () => clearInterval(id);
}
const stop = start();
stop();Every setInterval, setTimeout, addEventListener and observer you create needs a matching teardown, and the teardown must be reachable by whoever owns the lifecycle.
Leak 2 — the detached DOM node
const cache = new Map();
function show(id) {
const node = document.createElement('div');
cache.set(id, node); // the Map holds it
document.body.append(node);
}
function hide(id) {
cache.get(id)?.remove(); // removed from the page…
// …but the Map still references it, so it stays in memory
}
// Fix: cache.delete(id) — or use a WeakMap so the entry disappears with the node.
Leak 3 — the cache that only grows
// Strong keys: the Map keeps every user object alive forever.
const strong = new Map();
// Weak keys: when nothing else references the user, the entry goes too.
const weak = new WeakMap();
let user = { id: 1, name: 'Ada' };
strong.set(user, { lastSeen: Date.now() });
weak.set(user, { lastSeen: Date.now() });
console.log('strong has it:', strong.has(user));
console.log('weak has it:', weak.has(user));
user = null; // the only other reference is gone
console.log('the strong Map still holds an entry:', strong.size);
console.log('the WeakMap entry is now eligible for collection (size is not observable)');
Leak 4 — accidental globals and growing arrays
// Without strict mode this creates window.total — alive for the page's lifetime.
function add(n) { total = (total ?? 0) + n; }
// Modules are strict by default, so this throws instead. That is a feature.
| Symptom | Likely cause | Where to look |
|---|---|---|
| Memory climbs while idle | a timer or interval still running | DevTools → Performance → recorded timers |
| Memory climbs on navigation | listeners or observers not removed | detached nodes in a heap snapshot |
| Memory climbs under load then plateaus | normal caching | usually fine |
| One huge retained object | a closure capturing a big structure | retaining path in a heap snapshot |
Try it yourself
WeakMap for private per-object data
const privateData = new WeakMap();
class User {
constructor(name, ssn) {
this.name = name;
privateData.set(this, { ssn }); // not visible on the instance
}
lastFour() {
return privateData.get(this).ssn.slice(-4);
}
}
const u = new User('Ada', '123-45-6789');
console.log(u.name); // 'Ada'
console.log(u.lastFour()); // '6789'
console.log(JSON.stringify(u)); // {"name":"Ada"} — the ssn never leaks
console.log(Object.keys(u)); // ['name']
Replace the WeakMap with a Map and think about what changes. Which version could keep a deleted user in memory?
Exercises
Make a leak-free interval
Write createTicker(onTick, ms) that starts an interval and returns an object with stop() and isRunning(). Calling stop() twice must be safe, and after stopping, onTick must never fire again.
Check yourself
- When is a JavaScript value eligible for garbage collection?
- When nothing reachable from a root can reach it — Reachability is the whole rule. Going out of scope usually makes something unreachable, but not if a closure, a
Map, a timer or a listener still holds a reference to it. - Which of these keeps a removed DOM node in memory?
- A
Mapthat still has the node as a value — A regularMapholds strong references, so it keeps the detached node alive. AWeakMapkeyed by the node does not, which is exactly why it exists.
Common mistakes
- Adding listeners or intervals without a matching removal path.
- Caching DOM nodes in a long-lived
Map. - Assuming
WeakMapis a general performance upgrade — it is about lifetime, not speed.
Takeaways
- Reachability decides collection, not scope exit.
- Every subscription needs an unsubscription that someone can actually call.
WeakMapandWeakSetlet you attach data to an object without extending its life.- Find leaks with two heap snapshots and the retaining path, not by guessing.