Capture is a reference, not a copy
When you define a function inside another function, the inner function keeps a link to the environment it was created in. That environment holds bindings, not values, so the inner function sees whatever the variable holds at the moment it runs rather than what it held at the moment the function was defined. This is the whole mechanism, and almost every closure question is really a question about that distinction.
function counter() {
let n = 0;
return { inc: () => ++n, read: () => n };
}
const c = counter();
c.inc();
c.read();
The classic loop result follows directly. A var declaration in a for head creates one binding for the whole loop, so every callback created inside the loop shares it and reads the final value. A let declaration creates a fresh binding per iteration, so each callback closes over its own. Nothing about timing changes; only how many bindings exist.
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i));
for (let j = 0; j < 3; j++) setTimeout(() => console.log(j));
A leak is a retaining path, not a missing free
JavaScript is garbage collected, so you never fail to release memory directly. What you do instead is leave a chain of references from something the collector treats as a root — the global object, the DOM tree, a live task — down to data you are finished with. A closure makes that chain easy to create without noticing, because the reference is implicit: you stored a small function, and the scope it dragged along was not small.
The three long-lived roots that catch people are the same three every time. A listener registered on window or document lives until you remove it. A setInterval callback lives until you clear it, and holding the returned id is not the same as clearing it. A module-level cache keyed by something that never expires lives forever by design, and if the cached value is a closure then everything that closure captured lives with it.
function attach(node, records) {
const onScroll = () => node.classList.toggle('pinned', scrollY > 100);
addEventListener('scroll', onScroll);
}
The fix is not to null things out defensively. It is to give every registration an ending. An AbortController is the most economical way to do that when several listeners share a lifetime, since one abort() call detaches all of them and the option is supported across current browsers.
const controller = new AbortController();
addEventListener('scroll', onScroll, { signal: controller.signal });
addEventListener('resize', onResize, { signal: controller.signal });
controller.abort();
The sibling closure nobody suspects
Here is the part that separates someone who has debugged a real leak from someone who has read about closures. Engines do not allocate a separate environment per inner function. V8 allocates one context object for the enclosing scope and puts into it every variable that any inner function references. Two closures declared side by side therefore share that context, and keeping either one alive keeps the whole context alive — including variables that only the other closure ever touched.
function build() {
const rows = new Array(1e6).fill('row');
const ok = () => 'ok';
= () => rows.;
ok;
}
Returning ok alone looks obviously safe and is not, because rows was promoted into the shared context on account of count. If you need the small function to be genuinely small, it has to be created in a scope that does not contain the large data at all.
Confirm it rather than reason about it
Retention arguments are easy to get wrong, so the answer that earns credit ends with a measurement. Take a heap snapshot in DevTools, exercise the suspected flow several times, take a second snapshot and compare allocations between them. Then select the object that should have been collected and read its retainers path, which names every reference from a root down to it. A system / Context entry in that path is the signal that a closure is what is holding the object, and the function shown next to it tells you which registration you never undid.
Ask not "what does this function capture" but "what still points at this function", because the second question is the one that decides whether the memory is freed.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).