Web Platform · JavaScript Runtime

Execution Contexts, Environment Records, and What a Closure Keeps Alive

How identifier resolution walks a chain of environment records, why the per-iteration binding of let changes closure behaviour, and which storage a closure pins in memory.

A function that references an outer variable works after its enclosing call has returned, so something must persist past the return. The language specifies that something as a chain of environment records attached to each execution context. A closure is not a copy of the variables it uses; it is a reference to the environment in which the function was created. Understanding which environment that is, and which bindings it holds, explains the loop-variable puzzle, the memory retained by long-lived callbacks, and why some closures defeat engine optimizations.

What a call sets up

Invoking a function creates an execution context: a record holding the function’s lexical environment, a variable environment, a this binding, and a reference to the function object. The lexical environment is a chain — each entry has an outer reference, forming a linked list that ends at the global environment record. Identifier resolution walks that list from innermost outward and stops at the first record that has a binding for the name, whether or not it has been initialized.

Each function invocation produces a new function environment record containing its parameters and its local var and function declarations. Blocks that contain let, const, or class declarations produce block environment records nested inside it. The chain is therefore not one entry per function on the call stack; it is one entry per scope that holds a binding, and a chain of several entries can exist inside a single function body.

The global environment record is special: it is an object environment record whose bindings correspond to properties of the global object for var and function declarations, plus a separate declarative part for let and const. That double structure is why var x = 1 creates a property of window while let x = 1 does not, why var x can be declared twice in the same scope while a repeated let is a syntax error, and why a global var cannot be deleted.

Resolution order is lexical, not dynamic

The chain follows the source text. A function’s outer reference is fixed when the function object is created, not when it is called, so a callback invoked from a completely different part of the program still sees the variables of its defining scope. That is the entire point of lexical scoping, and it is what makes a module’s private state private.

function counter() {
  let count = 0;              // captured by both closures below
  return {
    increment() { return ++count; },
    read() { return count; }
  };
}
const c = counter();
c.increment();
console.log(c.read());        // 1; `count` lives in the shared environment

Both methods close over the same function environment record, so they share count rather than each holding a snapshot. Creating a second counter calls the function again and produces a new environment record with its own count. The identity of the environment record, not the name, is what determines sharing.

Why the loop variable behaves differently

The classic failure with var in a loop is a scoping problem, not a timing problem:

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

With var, the loop body shares the function’s single environment record, so all three closures see the same binding, whose value is 3 once the loop exits. With let, the specification requires a fresh binding per iteration: before each iteration’s body runs, a new environment record is created, the previous iteration’s values are copied into it, and the closures created in that body capture that record. The copy is what preserves the per-iteration value across the increment.

The mechanism is called CreatePerIterationEnvironment, and it applies to for, for-in, and for-of loops with lexical declarations in the head. It does not apply to while loops, because there is no per-iteration binding to recreate — a let declared inside a while body is already a new binding each time the block is entered.

This is also why closures over loop variables allocate: each of the n iterations creates an environment record, so memory grows with the iteration count when the closures escape. A loop over a million items that pushes closures into an array holds a million environment records, each with its own binding slot.

What is actually retained

The environment record holds the bindings for its scope. In an unoptimized model, every invocation of a function with any nested function would allocate a record containing every local variable. Engines avoid that with a static analysis pass: a variable is context-allocated only if some nested function references it, or if the scope is otherwise forced to materialize — by eval, or by a variable declared in a with statement’s scope. Variables that stay on the stack are not reachable from a closure and do not extend lifetimes.

The granularity is the scope, not the variable. Once a scope is context-allocated, all of its context-allocated slots live in the same context object, so a closure that uses one variable can keep alive other variables from the same scope that nothing else references anymore. A handler that captures a single identifier from a large function can therefore pin objects the handler never mentions. The usual remedy is to narrow the scope: compute the value the handler needs in an inner block, or pass it as an argument so it becomes a parameter of the handler’s own environment.

Garbage collection is by reachability, so the environment record stays alive exactly as long as some reachable closure references it. Cycles are fine — a closure stored on an object it also references is collected by a tracing collector, which browsers have used for JavaScript heaps for decades. The leaks that matter are unintentional roots: a listener never removed from a long-lived element, a global array that accumulates callbacks, a timer interval that is never cleared.

Detached DOM nodes are the canonical case. If a closure retains a reference to a node that has been removed from the document, the node’s entire subtree can remain reachable through that reference, even though it can never be rendered. The fix is to release the reference when the element is removed, not to expect the removal to release the closure’s environment.

this is part of the environment too

this is bound per execution context, and an arrow function does not get a new binding: it captures the this value of the enclosing context. The capture is implemented through the same environment chain, so an arrow function created inside a method retains the method’s this even when invoked as a free function. Extracting a method and calling it detached does the opposite: the method’s this becomes undefined in strict mode, and the failure surfaces as a property access on undefined rather than as an arity error.

Class fields initialize this before the constructor body runs in a defined order, which matters for closures created in field initializers: they observe the instance under construction, and a subclass field initializer runs after the base constructor returns, so a callback captured in a base-class field sees the half-initialized object if it is invoked during base construction.

Where dynamic scope sneaks back in

Two constructs defeat static resolution. with inserts an object environment record for the duration of its block, so identifiers inside it may resolve to properties of the object; the engine cannot know statically which, so it must keep the environment accessible and skip optimizations for the enclosing scope. Direct eval can introduce var declarations into the calling function’s variable environment, so a function containing a direct eval call cannot have its locals kept on the stack.

Both are rare in modern code and both are avoided for the same reason: they make the environment observable and mutable in ways the optimizer cannot reason about — the same assumption-checking that inline caches depend on — and the cost is paid by every invocation of the containing function, not only the ones that take the dynamic path. Indirect eval — (0, eval)(src), which evaluates in global scope — does not have this effect on the caller, which is why some libraries use it for feature detection.

The chain ends at the global environment. Because it is reachable from every function, anything stored there is a permanent root; module-level let bindings are collected only if the module itself becomes unreachable, which for a loaded module in a live realm means never. Long-lived state belongs in a closure or an object with an explicit lifetime, not at module scope, unless the intent is to keep it for the lifetime of the page.

References

  1. ECMAScript Language Specification — Lexical Environments — Ecma International
  2. ECMAScript Language Specification — Execution Contexts — Ecma International
  3. JavaScript: The Definitive Guide, 7th edition — David Flanagan; O'Reilly Media