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
- ECMAScript Language Specification — Lexical Environments — Ecma International
- ECMAScript Language Specification — Execution Contexts — Ecma International
- JavaScript: The Definitive Guide, 7th edition — David Flanagan; O'Reilly Media