What Actually Happens When a Closure Outlives Its Function

"A closure is a function that remembers its outer scope" is the sentence every JavaScript developer has memorized and almost none can act on. It tells you what a closure does, not what it is — and that gap matters the moment you're debugging a retained heap snapshot in Chrome DevTools or trying to explain why a let inside a for loop behaves differently than a var did five years ago. A closure isn't a copy, a snapshot, or a clever trick the engine performs at call time. It's a live reference to a lexical environment — a data structure that exists independently of the function that created it, for as long as something can still reach it.

The Lexical Environment Is Not the Function Body

Every time a function is invoked, the JavaScript engine creates an Environment Record — an internal object, defined in the ECMAScript specification, that maps every variable and parameter name declared in that function to its current value. This record doesn't vanish when the function returns. It's kept alive by a reference chain, and that chain is exactly what a closure is: an inner function holding a pointer to the environment record of the function that defined it, which itself may hold a pointer to its enclosing environment, all the way up to the global environment record.

Global Environment Record
        │
        ▼
outer() Environment Record
  ├─ count: 0
  └─ inner ──────────┐
                      ▼
              inner() Function Object
              [[Environment]] → outer()'s record

When inner is returned from outer and called later, it doesn't look up count in its own scope — it doesn't have one for that variable. It walks the scope chain: check the local environment record first, then follow [[Environment]] to the parent record, and so on, until the binding resolves. This is why closures can read and mutate variables from an enclosing scope rather than working off a frozen copy — there's only ever one environment record for count, and every closure that references it is looking at the same object.

javascript

function makeCounter() {
  let count = 0;
  return {
    increment: () => ++count,
    value: () => count,
  };
}

const counter = makeCounter();
counter.increment();
counter.increment();
console.log(counter.value()); // 2

Both increment and value close over the same environment record from makeCounter's invocation, not two separate copies of count. That's the mechanism private state relies on — count is unreachable from outside the closure's scope chain, which is a stronger guarantee than a #private class field, since there's no bracket-notation or reflection path that exposes it.

Why var and let Produce Different Closures in a Loop

The classic interview gotcha — a loop that logs 3, 3, 3 instead of 0, 1, 2 — is a direct consequence of how many environment records get created, not a quirk of timing.

javascript

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// logs: 3, 3, 3

for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log(j), 0);
}
// logs: 0, 1, 2

var is function-scoped, so the entire loop shares a single environment record — every callback closes over the same i, and by the time the timers fire, the loop has finished and i is 3. let is block-scoped, and the specification requires the engine to create a new environment record per iteration, copying the current value of j forward into it. Each setTimeout callback closes over a distinct record, so each one sees the value that was current for its own iteration. This isn't syntactic sugar — it's a different number of heap-allocated objects, and it's the same mechanism that makes let-scoped closures in map/forEach callbacks behave predictably without needing an IIFE wrapper, which was the standard 2014-era workaround for exactly this problem.

The Memory Cost Nobody Puts in the Benchmark

Because a closure keeps its entire enclosing environment record alive — not just the variables it actually references — closures are one of the most common root causes behind the kind of slow, hard-to-trace RSS growth covered in TVerge's breakdown of a Node.js memory leak. A closure attached to a long-lived event emitter, a cache, or a module-level array will keep the entire environment record — and everything it points to — reachable for the life of that reference, even if the closure only ever reads one field from it.

V8 mitigates part of this cost through escape analysis, a compiler optimization that determines whether a variable's environment record can be proven to never leave the function that created it. If V8 can prove no closure captures a given local, it allocates that variable on the stack instead of the heap, and it's reclaimed the instant the function returns — no garbage collection involved. The moment any inner function might reference a variable, escape analysis has to assume it does, and the variable's environment record is heap-allocated for the closure's lifetime. This is why writing an inner function that merely could capture a variable — even one it never actually reads — can measurably change a function's allocation profile under V8's optimizing compiler, TurboFan.

Comparison — heap-retained closure vs. stack-local function: a function with no inner closures lets V8 allocate its locals on the stack and discard them on return; a function that returns or stores an inner function forces the entire enclosing environment record onto the heap for as long as that inner function is reachable, regardless of how many of the record's bindings the closure actually uses.

Patterns That Rely on the Mechanism, Not the Metaphor

Three idioms use closures because of what an environment record structurally guarantees, not as a stylistic preference:

  • Memoization — a cache object created inside the memoizing function is only reachable through the closure returned to the caller, giving each memoized function its own private cache without a module-level Map that different calls could collide on.
  • Currying and partial application — each returned function closes over the arguments already supplied, and because environment records are chained rather than flattened, a three-level curry (add(1)(2)(3)) walks three separate scope chains, not one merged argument list.
  • Module patterns predating ES modules — the IIFE-with-returned-object pattern is a closure exposing selected bindings from its environment record while leaving the rest unreachable, which is mechanically how export in a native ES module also enforces what's public: unexported bindings simply have no external reference path into the module's environment record.

The same reference-chain logic shows up any time a callback is built inside a loop or a .reduce() accumulator, which is exactly the pattern TVerge's walkthrough on migrating manual grouping logic to Object.groupBy() replaces — a hand-rolled reduce() callback that closes over an accumulator object is functionally a closure-based grouping implementation, and the native static methods remove the manually maintained environment record entirely.

What This Means for How You Write Closures

The specification-level model — environment records chained by [[Environment]], kept alive only by reachability — explains both halves of the closure story that get taught separately: why closures can implement genuinely private state with no workaround-proof leak path, and why they're a leading cause of retained memory in long-running Node.js processes. Treating a closure as "remembering variables" hides the fact that it's holding a reference to a shared, mutable, heap-allocated record — which is exactly the detail that matters when you're deciding whether an event handler, a cache, or a factory function should be allowed to keep one alive.

Sources: MDN — Closures, ECMA-262 — Environment Records, V8 Blog — Escape Analysis