Skip to content
QSWEQB
mediumConceptCodingEntryMidSenior

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.

4 min readUpdated 2026-07-26

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

Further reading

closuresscopememory-leaksevent-listenersjavascript