Closures don't capture variables — they capture environments
Every senior FE dev can explain closures. Almost nobody points to the ECMA-262 Environment Record and the [[Environment]] slot on function objects. Here's the spec-level story, with a step-through of the heap and a primary-source walk of why `let` fixes the for-loop trick.
Step through the heap and watch what your closure is actually holding onto:
1function makeCounter() {2 let count = 0;3 return function inc() {4 return ++count;5 };6}7const c = makeCounter();8c();9c();
There's a sentence you have read a thousand times:
"A closure captures the variables from its enclosing scope."
It's wrong. Not wrong in a way that matters for MDN. Wrong in a way that matters the moment you hit a bug the model can't explain. The ECMA-262 spec has no concept of "capturing a variable". What it has is an abstract data structure called an Environment Record, and a slot on every function object called [[Environment]] that points at one.
This post walks the spec — primary sources only — and by the end you'll be able to explain, without hand-waving:
- What the runtime keeps alive when a function "closes over" a name.
- Why two closures from the same outer call share state, but closures from two different outer calls don't.
- Why
letinside aforloop produces different output fromvar, and where in the spec that behaviour is mandated.
A closure is a function object whose [[Environment]] internal slot points at an Environment Record. An Environment Record is a heap-allocated map from names to bindings, with an [[OuterEnv]] pointer to its parent. When you "close over count", the runtime doesn't snapshot the value — it keeps a reference to the Environment Record count lives in. That's why count can still change after the outer function returned, and every closure from the same call sees the same mutations.
The primitive: Environment Record
Not "scope". The spec's type for the thing that holds identifier bindings is Environment Record. (The word "LexicalEnvironment" still appears in the spec — but only as the name of a component of an execution context; the record type itself was renamed to Environment Record in ES2019.)
Environment Record is a specification type used to define the association of Identifiers to specific variables and functions, based upon the lexical nesting structure of ECMAScript code. Usually an Environment Record is associated with some specific syntactic structure of ECMAScript code such as a FunctionDeclaration, a BlockStatement, or a Catch clause of a TryStatement. Each time such code is evaluated, a new Environment Record is created to record the identifier bindings that are created by that code.
Two things to notice:
- "Each time such code is evaluated, a new Environment Record is created." Every call to a function creates a fresh Environment Record. This is why two calls to
makeCounter()produce two independent counters. - The record "records the identifier bindings that are created by that code." It stores bindings, not values. A binding is a name plus storage; the value in that storage can change over time.
Next, the chain:
Every Environment Record has an [[OuterEnv]] field, which is either null or a reference to an outer Environment Record. This is used to model the logical nesting of Environment Record values. The outer reference of an (inner) Environment Record is a reference to the Environment Record that logically surrounds the inner Environment Record.
[[OuterEnv]] is the scope chain. Name lookup for identifier x walks the current Environment Record, then [[OuterEnv]], then its [[OuterEnv]], until it hits the Global Environment Record (whose [[OuterEnv]] is null) or finds a binding.
The spec defines three concrete subclasses, with Function and Module as further subclasses of Declarative:
- Declarative — concrete. Backs syntactic binding forms:
{ block },catchclauses, function bodies after declaration instantiation. Holdsvar/let/const/functionbindings directly. - Object — backs a scope with an arbitrary object. Used by the
withstatement and as the object side of the Global Environment Record. - Global — the top of every chain.
[[OuterEnv]]isnull. Composites an Object record (for the global object) and a Declarative record (forlet/constat global scope). - Function — a subclass of Declarative. Created when an ECMAScript function is invoked. Adds
[[ThisValue]],[[FunctionObject]],[[NewTarget]]. - Module — a subclass of Declarative. The top of a Module's chain.
[[OuterEnv]]is the Global Environment Record.
This is the only scope model the spec knows. "The closure captured count" is an informal shortcut for "the function object's [[Environment]] slot points at an Environment Record that contains a binding for count".
The slot that makes a closure a closure
Function objects in ECMA-262 have a table of internal slots. One of them is the closure's anchor:
[[Environment]] — an Environment Record. The Environment Record that the function was closed over. Used as the outer environment when evaluating the code of the function.
Where does that slot get filled in? At function creation time, in the abstract operation OrdinaryFunctionCreate — the operation the spec runs for every function expression, function declaration, arrow function, and method:
11. Let internalSlotsList be the internal slots listed in Table 25.22. Let F be OrdinaryObjectCreate(functionPrototype, internalSlotsList).3...413. Set F.[[Environment]] to env.514. Set F.[[PrivateEnvironment]] to privateEnv.6...723. Return F.env is the Environment Record of whatever code is running when the function gets created. That single assignment in step 13 is the entire mechanical basis of closures. No snapshot. No copy. No capture list. Just: "remember which Environment Record you were born in."
Re-reading the trace
Now that you've played with the step-through at the top, go back to the moment makeCounter returns (step 5). The execution context gets popped off the stack. But ER1 — the Environment Record that context created — stays alive on the heap. That's the thing everyone waves at and nobody pins down.
A few things the trace makes concrete:
- ER1 is on the heap, not the stack. Execution contexts live on the stack and get popped. Environment Records live on the heap and survive as long as something references them.
F_inc.[[Environment]]is a reference, not a snapshot. That's why thecountyou read in call #2 is the value you wrote in call #1.- Each inner call (ER2, ER3) gets its own Environment Record whose
[[OuterEnv]]points at ER1. Name lookup forcountwalks outward from the current env until it finds the binding.
The spec phrases the "nothing observable" clause deliberately:
"Environment Records are purely specification mechanisms and need not correspond to any specific artefact of an ECMAScript implementation. It is impossible for an ECMAScript program to directly access or manipulate such values."
V8 implements this with a Context object, but the spec doesn't require that specific shape. Another engine is free to pick any data structure that produces the same observable behaviour.
Now the for-loop trick
Here's the classic you've seen in every interview:
1for (var i = 0; i < 3; i++) {2setTimeout(() => console.log("var:", i), 0);3}4for (let j = 0; j < 3; j++) {5setTimeout(() => console.log("let:", j), 0);6}The usual explanation is "let is block-scoped, so each iteration gets its own j". True but hand-wavey. The spec is more precise. For a for (let …; …; …) statement, every iteration creates a fresh Declarative Environment Record and copies the binding value into it.
- If perIterationBindings has any elements, then
a. Let lastIterationEnv be the running execution context's LexicalEnvironment.
b. Let outer be lastIterationEnv.[[OuterEnv]].
c. Assert: outer is not null.
d. Let thisIterationEnv be NewDeclarativeEnvironment(outer).
e. For each element bn of perIterationBindings, do
i. Perform ! thisIterationEnv.CreateMutableBinding(bn, false).
ii. Let lastValue be ? lastIterationEnv.GetBindingValue(bn, true).
iii. Perform ! thisIterationEnv.InitializeBinding(bn, lastValue).
f. Set the running execution context's LexicalEnvironment to thisIterationEnv.
Read that carefully. Every iteration of a for (let …) loop:
- Looks at the current iteration's Environment Record (
lastIterationEnv). - Creates a new Declarative Environment Record sharing the same
[[OuterEnv]]. - Creates a new binding for each loop variable in the new env.
- Copies the value (not the binding) from the old env into the new env.
- Switches the running env to the new one before the next iteration body runs.
When your inner setTimeout callback is created during iteration 0, its [[Environment]] slot points at the iteration-0 Environment Record. When iteration 1's body runs, the running env is a different Environment Record with a different j binding. Three iterations produce three Environment Records and three closures, each anchored to its own.
var doesn't get that treatment. var i is hoisted to the enclosing Function Environment Record and stays in a single binding across every iteration. All three callbacks close over the same record. By the time they fire, the binding's value is 3.
Step through both loops side by side — one env on the left, a fresh env per iteration on the right:
This is not an implementation detail a browser vendor could choose to change. It is a normative step in the spec, and it is the only reason let in a loop behaves differently from var.
Proving environments are shared, not snapshotted
A sibling closure can see the outer's mutations — if they were snapshots, this wouldn't work.
1function makePair() {2let n = 0;3return {4 get: () => n,5 bump: () => { n += 1; },6};7}8 9const { get, bump } = makePair();10console.log(get()); // 011bump();12bump();13console.log(get()); // 2 — same Environment Record, mutated in placeget and bump were both created during the same makePair() call. Their [[Environment]] slots point at the same Environment Record. When bump mutates n, get reads the mutation. No state is passed between them. They communicate through the shared heap record.
Now call makePair() twice and you get two separate records:
1function makePair() {2let n = 0;3return { get: () => n, bump: () => { n += 1; } };4}5 6const a = makePair();7const b = makePair();8 9a.bump(); a.bump(); a.bump();10b.bump();11 12console.log("a:", a.get()); // 313console.log("b:", b.get()); // 1 — independent Environment RecordSame source, two independent heap records, because each call to makePair() ran OrdinaryFunctionCreate afresh and filled in [[Environment]] with a freshly created Function Environment Record.
What this buys you
A precise model pays off when the bug doesn't fit the informal one:
- "Why does my debounced function remember the wrong arguments?" The debounced wrapper's
[[Environment]]still points at the env where the old arguments were bound. You have to pass fresh values through the call; don't expect the closure to forget. - "Why is this React callback stale?" Same reason.
useCallbackwith the wrong deps locks[[Environment]]to the render where the callback was born. The fix isn't "add a ref". The fix is "recreate the function so[[Environment]]points at the current render's env". - "Why do these two modules share state I didn't intend to share?" They were created in the same Module Environment Record. Name lookup walks
[[OuterEnv]]. If both close over the same outer binding, both see every mutation.
None of those debugging stories require knowing the spec. All of them become obvious once you do.
Primary sources
- ECMA-262 §9.1 Environment Records — defines the
Environment Recordspecification type and the[[OuterEnv]]field. - ECMA-262 §9.1.1 The Environment Record Type Hierarchy — lists Declarative / Object / Function / Global / Module.
- ECMA-262 §9.1.1.3 Function Environment Records — adds
[[ThisValue]],[[FunctionObject]],[[NewTarget]]. - ECMA-262 §10.2 Table 25 — Internal Slots of ECMAScript Function Objects — the
[[Environment]]slot. - ECMA-262 §10.2.3 OrdinaryFunctionCreate — step 13: "Set F.[[Environment]] to env."
- ECMA-262 §14.7.4.4 CreatePerIterationEnvironment — the
let-in-a-for-loop per-iteration env.