What is a closure, and how does one end up leaking memory?
A closure is a function plus the scope it was created in, held by reference rather than copied. It leaks when something long-lived keeps the function alive, because everything the captured scope can reach stays reachable with it.
What the interviewer is scoring
- Does the candidate describe capture as a live reference to a scope rather than as a snapshot of values
- Whether they can explain the var-versus-let loop result from the binding rules instead of reciting it as a quirk
- That they connect a leak to a retaining path from a long-lived root, not to "forgetting to free memory"
- Whether they know closures declared in the same scope can share one context, so one of them keeps the others' variables alive
- Does the candidate propose a way to observe the retention rather than only a way to guess at it
Answer
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;
// Both functions close over the same binding for n, so they agree.
return { inc: () => ++n, read: () => n };
}
const c = counter();
c.inc();
c.read(); // 1
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)); // 3 3 3
for (let j = 0; j < 3; j++) setTimeout(() => console.log(j)); // 0 1 2
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);
// records is never used above, but it is in the captured scope.
// Nothing removes the listener, so window keeps onScroll,
// onScroll keeps the scope, and the scope keeps node and records.
}
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(); // both gone, no reference to the handlers needed
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'; // never touches rows
const count = () => rows.length; // does
return ok; // and yet rows may be retained
}
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.
Likely follow-ups
- Two callbacks are created side by side and only one of them is stored. What could still be retained, and how would you check?
- Why does removeEventListener with an inline arrow function fail to remove anything?
- When would you reach for WeakMap or WeakRef, and what does each one not guarantee?
- How does a closure over a DOM node keep an entire detached subtree alive?
Related questions
- How is `this` determined in JavaScript, and why does a method lose it when you pass it as a callback?mediumAlso on javascript4 min
- Why do the functions you build inside a loop all end up returning the last value?mediumAlso on closures4 min
- How do goroutines leak, and how would you find a leak in a running service?mediumAlso on memory-leaks5 min
- When would you use the observer pattern, and what goes wrong with it at scale?mediumAlso on memory-leaks4 min
- You have been handed a scope and a budget, and the budget cannot buy the scope. What do you put in front of the customer?hardAlso on scope5 min
- How do you turn discovery into a proposed architecture and a written proposal that survives procurement?hardAlso on scope7 min
- Adding a second LEFT JOIN doubled the revenue figure on a report. Explain what happened and write the correct query.mediumSame kind of round: coding4 min
- Your test still reaches the network even though you patched the client. Talk me through fixture scope, parametrisation, and where a patch has to point.mediumSame kind of round: concept4 min