skip to content

In JavaScript, why is a function's local variable not freed when that function returns, if an inner function it returned still refers to the variable?

level: juniorimportance: must knowfreq 58%

answer

  1. lifetime follows reachability
  2. the returned function points at its birth scope
  3. hidden [[Environment]] slot on the function
  4. stack frame dies, environment record does not
  5. freed only when the closure itself is unreachable

basics

~20 s

A function object stores a hidden pointer to the scope it was created in, so returning it keeps that scope reachable. Reachable memory is never collected, so the variable lives as long as the returned function does.

solid answer

~50 s

When a function literal is evaluated, the resulting function object stores a hidden reference to the scope record it was born in — the spec calls the slot `[[Environment]]` — and that record links outward to its enclosing scopes. Returning the inner function makes that whole chain reachable from outside the call, and a garbage collector only reclaims what is unreachable, so the bindings stay alive. In other words, a captured variable's lifetime is governed by reachability, not by whether the outer call has finished; the stack frame goes away, the scope record does not. The memory consequence is what interviewers are actually after: if that scope held a large object, storing the returned callback somewhere long-lived — a module-level array, a registry, a cache — silently keeps the large object in the heap too, until the callback itself becomes unreachable.

code

javascript · 9 lines
javascript
function makeReader() {
  const buffer = new Uint8Array(8 * 1024 * 1024);
  return () => buffer.length;
}

let read = makeReader();
console.log(read()); // 8388608 - buffer is alive after makeReader returned

read = null; // last reference gone: closure, environment and buffer are collectable

go deeper

for a junior

Be able to say plainly that a returned inner function keeps the scope it was created in alive, so those variables are not freed when the outer call ends, and that the memory comes back only when the returned function itself is no longer referenced.

for a middle

Explain the mechanism: the function object holds a reference to its creation environment, that environment record chains outward, and collection is driven by reachability from roots rather than by stack frames unwinding.

for a senior

Show you think in retained graphs, not variables: name a concrete case where a small long-lived callback pins a large object it barely uses, and describe how you would restructure the capture so the graph is not retained in the first place.

for a principal

Own the policy angle: where in the system it is acceptable for callbacks to live for the process lifetime, what ownership rules keep those capture sets small, and how you would make retention a reviewable property rather than something discovered in production.

## The mechanism: a function remembers where it was born When the engine evaluates a function definition it creates a function object, and that object carries a hidden internal slot the specification names `[[Environment]]`: a pointer to the *Environment Record* that was active at the point the function literal appeared. An Environment Record is the runtime structure that holds one scope's bindings — its parameters, `var`s, `let`s, `const`s and function declarations — plus an `[[OuterEnv]]` pointer to the record of the enclosing scope. A closure is nothing more exotic than that pair: the function's code together with the captured environment pointer. When you later call the inner function, the engine builds a fresh execution context whose scope chain starts at a new record whose outer link is the function's `[[Environment]]`. That is the whole reason the inner function can still resolve an identifier that was declared in a call which returned long ago. ```js function makeReader() { const buffer = new Uint8Array(50 * 1024 * 1024); // 50 MB return function size() { return buffer.length; }; } const read = makeReader(); // makeReader has returned... console.log(read()); // ...yet `buffer` is still there ``` ## Lifetime is reachability, not stack depth The intuition most people import from other languages is that a local variable lives in the call's stack frame and dies when the frame is popped. In JavaScript the frame is popped just the same, but a captured binding does not live in it — it lives in a heap-allocated environment record that the function object points at. Collection is driven purely by reachability: an object survives while it can still be reached from a root (the global object, module scope, a live stack frame, or anything else already reachable). So the chain is: `read` is reachable from module scope → `read.[[Environment]]` is reachable → the `buffer` binding inside it is reachable → the 50 MB typed array it points at is reachable. Nothing in that chain can be reclaimed. ## What you keep is a graph, not a variable The retained set is not "one variable" but everything transitively reachable from the retained bindings. A callback that captures a `config` object keeps `config`, every object `config` references, and so on. This is why the honest one-line summary is "a closure keeps its environment alive", not "a closure keeps a value alive". A single small callback stored in a long-lived place can pin an object graph orders of magnitude bigger than itself. ## Releasing it There is exactly one lever the language gives you: make the retaining reference unreachable. ```js let read = makeReader(); read = null; // the function object, its environment, and the 50 MB become unreachable ``` If the closure is held in an array, a `Map`, or an object property, removing it from there does the same job. Note that `delete` on a variable does not apply — `delete` removes object properties, not bindings — and there is no `free()` in JavaScript. ## What the specification actually promises ECMAScript does not specify garbage collection at all; when memory is reclaimed is an engine decision and is deliberately not observable from ordinary code (the narrow exceptions, `WeakRef` and `FinalizationRegistry`, arrived in ES2021 and are explicitly non-deterministic). Engines are also free to *drop* what a closure provably never reads — a real optimization discussed elsewhere. The guarantee you can lean on is the direction that matters here: anything a live closure could still read will never be collected. ## Why interviewers ask it The question is the counterweight to the usual closure enthusiasm. A candidate who can only say "closures remember their variables" has learned the feature; a candidate who adds "…which means storing that callback extends the lifetime of everything its scope held" has understood the cost. The follow-up is almost always practical: what would you do if that callback lives for the whole process? ```js const handlers = []; function register() { const rows = loadHugeDataset(); // used once handlers.push(() => rows.length); // ...retained forever } ``` The fix in that snippet is to capture only what the callback needs (`const count = rows.length;`) so the dataset is not part of the retained graph at all.

  • If the returned function never references any variable from the outer scope, is anything still retained?
    In practice no. The function object exists, but engines only heap-allocate the bindings an inner function actually captures, so a callback that reads nothing from its enclosing scope keeps nothing alive from it. The specification says only that the environment is reachable through `[[Environment]]`; since nothing observable depends on unread bindings, engines are free to omit them, and they do.
  • You cannot change the closure's code, but you need its memory back. What do you do?
    Drop the last reference to the closure itself, since that is what roots the environment. Null the field or variable holding it, remove it from whatever array, `Map` or registry keeps it, and make sure no other live object still points at it. Once the function value is unreachable, its environment and everything only that environment referenced become collectable.
  • Does calling the outer function twice share one environment or create two?
    Two. Each call creates its own Environment Record, so each returned closure captures a distinct set of bindings and pins its own copy of whatever those bindings reference. That is why calling a factory in a loop multiplies the retained memory: one hundred calls that each capture a one-megabyte object retain one hundred megabytes, not one.

saying these in an interview costs you the question

  • Says memory is freed the moment the outer function returns
  • Thinks the closure copies the variable's value, so nothing is retained
  • Believes only the small captured value is kept, not the object graph
  • Claims delete or setting the inner variable frees the outer one
  • Assumes garbage collection happens at a predictable, specified moment

context