The RuntimeApr 19, 2026·10 min read·

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();
heap — environment records
ER_G · Global Environment Record[[OuterEnv]] → null
execution context stack
global script
running env: ER_G
step 0 / 7
Before anything runs: only the Global Environment Record exists. [[OuterEnv]] is null.
// heap: [ER_G]
Step through a closure. Watch ER1 stay alive after makeCounter returns.

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 let inside a for loop produces different output from var, and where in the spec that behaviour is mandated.
tl;dr

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:

  1. "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.
  2. 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 }, catch clauses, function bodies after declaration instantiation. Holds var/let/const/function bindings directly.
  • Object — backs a scope with an arbitrary object. Used by the with statement and as the object side of the Global Environment Record.
  • Global — the top of every chain. [[OuterEnv]] is null. Composites an Object record (for the global object) and a Declarative record (for let/const at 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:

ECMA-262 §10.2.3 OrdinaryFunctionCreate (excerpt)text
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 the count you 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 for count walks 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:

demo.jsjs
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.

  1. 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:

  1. Looks at the current iteration's Environment Record (lastIterationEnv).
  2. Creates a new Declarative Environment Record sharing the same [[OuterEnv]].
  3. Creates a new binding for each loop variable in the new env.
  4. Copies the value (not the binding) from the old env into the new env.
  5. 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:

for (var i = 0; i < 3; i++) setTimeout(…)
shared Env Record
i = 0
closures created (0)
output:
for (let i = 0; i < 3; i++) setTimeout(…)
per-iteration Env Records
ER1 · i = 0
closures created (0)
output:
i = 0 — body runs
step 1 / 8
Both loops enter iteration 0. var's single Env Record holds i=0. let's CreatePerIterationEnvironment forks a fresh env with i=0.
One env on the left; a fresh env per iteration on the right — mandated by ECMA-262 §14.7.4.4 CreatePerIterationEnvironment.

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.

demo.jsjs
1function makePair() {
2let n = 0;
3return {
4 get: () => n,
5 bump: () => { n += 1; },
6};
7}
8 
9const { get, bump } = makePair();
10console.log(get()); // 0
11bump();
12bump();
13console.log(get()); // 2 — same Environment Record, mutated in place

get 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:

demo.jsjs
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()); // 3
13console.log("b:", b.get()); // 1 — independent Environment Record

Same 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. useCallback with 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