Skip to main content

Command Palette

Search for a command to run...

Part 3: Block Scope and the Two Pointers That Disagree

Updated
•29 min read•View as Markdown

The JavaScript Environment Model — Part 3 of 4. By the end of this post you'll know exactly when the EC's two pointers diverge, why they were designed that way, and why "var is function-scoped, let is block-scoped" is descriptively true but mechanically useless.


Part 2 closed with a tease: the EC has two Environment Record pointers, LexicalEnvironment and VariableEnvironment, and they diverge in exactly one situation. This post is about that situation.

Here's the puzzle:

function f() {
  if (true) {
    var a = 1;
    let b = 2;
  }
  console.log(a);  // 1
  console.log(b);  // ReferenceError: b is not defined
}
f();

Both a and b are declared inside the same block. Both are initialized before the block ends. Yet a survives outside the block and b doesn't. That much you probably knew.

Most tutorials describe this as "var is function-scoped, let is block-scoped." The description is descriptively true. It will get the right answer on every test case. It is also mechanically useless — it doesn't explain why the engine treats them this way. It's a name for the behavior, not a model of the mechanism.

Now try this one:

function f() {
  console.log(a);  // undefined
  console.log(b);  // ReferenceError: b is not defined
  if (true) {
    var a = 1;
    let b = 2;
  }
}
f();

Both console.log calls run before the block has been entered. The var a and let b declaration lines haven't executed yet. And yet — different errors. a is already undefined at that point. b doesn't exist at all.

"var is function-scoped, let is block-scoped" doesn't help you predict this. Both declarations are inside the block. If the block is what scopes them, both should be invisible outside. But a is already visible before the block even runs.

This post explains why. By the end you'll know:

  • That the EC has two pointers to Environment Records, and the situation in which they diverge.

  • The four-step ritual the engine performs when entering a block.

  • Why var declarations escape blocks: they target a pointer that doesn't move.

  • The historical reason JavaScript was designed this way.

  • How for-let loops give each iteration its own variable.

  • The difference between strict-mode and sloppy-mode function declarations inside blocks.

  • How a function defined inside a block captures the block's record — and how that survives all the way through a four-level nested chain.


Section 1: The EC has two pointers to Environment Records

In Part 2 I named the EC's two pointers and asked you to read them as equivalent for the whole post. They stayed equal. Time to admit they don't always.

Every Execution Context has two fields that point at Environment Records:

┌──────────────────────────────────────┐
│ Execution Context                                          │
├──────────────────────────────────────┤
│ LexicalEnvironment:  → some record                        │
│ VariableEnvironment: → some record                        │
│ ...other bookkeeping...                                    │
└──────────────────────────────────────┘

They serve different purposes:

  • LexicalEnvironment is used for two things: where let, const, and class declarations install their Bindings, and the starting point for every identifier lookup. When code reads or writes any variable, the engine starts at LexicalEnvironment and walks the [[OuterEnv]] chain from there.

  • VariableEnvironment is used for one thing only: where var declarations install their Bindings. It is never consulted for lookups.

At function entry, both pointers point at the same Environment Record — the fresh one the engine just created for the call. This was true throughout Part 2, which is why the simplification worked. The pointers stay equal until a specific event happens.

That event is: entering a block that contains at least one let, const, or class declaration.

Entering such a block causes the engine to perform a four-step ritual that creates a new Environment Record and changes one of the EC's pointers — but not the other. After this ritual, the two pointers point at different records, and var declarations inside the block end up in a different place than let/const declarations.

This pointer divergence is the entire mechanism behind var vs let scope behavior. There's no separate rule that says "var is function-scoped." There's a rule that says "var goes to VariableEnvironment," combined with the fact that VariableEnvironment doesn't change when you enter a block. The function-scoping behavior follows from the mechanism.


Section 2: What entering a block actually does

When the engine is executing your code and reaches a { that begins a block, it asks a single question: does this block contain any let, const, or class declarations? (Plus function declarations in strict mode — we'll cover this in Section 4.)

  • No → the braces are runtime no-ops. The engine treats them as syntax only. var declarations inside install themselves the same way they would outside.

  • Yes → the engine performs the four-step block-entry ritual.

The ritual:

  1. Create a new Environment Record. Call it blockRecord. It's an ordinary Environment Record — same structure as the function-level ones we've been working with. A bindings map and an [[OuterEnv]] pointer.

  2. Run a creation phase on the block's body. Find every let, const, and class declaration that is directly inside this block (not inside any nested block). For each, create a Binding in blockRecord with value EMPTY.

  3. Set blockRecord.[[OuterEnv]] = EC.LexicalEnvironment (whatever it currently is). The new block's parent in the scope chain is the previous lexical scope.

  4. Swap EC.LexicalEnvironment to point at blockRecord. Do not touch EC.VariableEnvironment.

After the ritual, the EC looks like this:

EC:
  LexicalEnvironment  → blockRecord       (just swapped)
  VariableEnvironment → f's record        (unchanged)

blockRecord:
  bindings: { (the block's let/const/class) }
  [[OuterEnv]] → f's record

f's record:
  bindings: { ... }
  [[OuterEnv]] → outer scope

The two pointers have diverged. They point at different records.

When the engine reaches the closing } of the block, it performs the reverse:

  • Restore EC.LexicalEnvironment to what it was before block entry — which conveniently equals blockRecord.[[OuterEnv]].

  • blockRecord is no longer pointed at by any EC. If nothing else points at it (no inner function captured it), it becomes garbage. Its Bindings die with it.

Let's trace this with the opening puzzle:

function f() {
  if (true) {
    var a = 1;
    let b = 2;
  }
  console.log(a);
  console.log(b);
}

Before the block — at function entry:

The engine runs f's creation phase. It scans the entire function body, including inside the block, looking for var and function declarations. (It does not yet look for let/const inside the block — those wait for the block-entry creation phase.)

It finds: var a inside the block. Creates a Binding for a in f's record. Value: undefined.

That last sentence is critical: var a is installed in f's record during f's creation phase, even though it's written syntactically inside the block. The block isn't entered yet at this moment. The function's creation phase scans through the block's source code, finds the var, and hoists the Binding all the way up to the function's record.

After f's creation phase:

f's record:
  bindings: { a: undefined }
  [[OuterEnv]] → script's env

EC for f:
  LE → f's record
  VE → f's record

There's no b anywhere yet. let b is not installed during f's creation phase — let/const only hoist to the nearest enclosing scope, which is the block, and the block hasn't been entered.

Execution begins. The first line is if (true) {. The engine evaluates the condition (true), then begins entering the block.

The block contains a let b declaration. So the engine performs the four-step ritual:

  1. Creates blockRecord.

  2. Block's creation phase: finds let b, creates Binding for b in blockRecord with value EMPTY.

  3. Sets blockRecord.[[OuterEnv]] = f's record.

  4. Swaps EC.LexicalEnvironment to blockRecord. VariableEnvironment stays pointing at f's record.

State after step 4:

blockRecord:
  bindings: { b: EMPTY }
  [[OuterEnv]] → f's record

f's record:
  bindings: { a: undefined }
  [[OuterEnv]] → script's env

EC for f:
  LE → blockRecord       (swapped)
  VE → f's record        (unchanged)

The two pointers have diverged. Both still exist on the same EC. They just point at different records.

Now var a = 1; runs. The engine wants to assign 1 to the a Binding. The question is: where does var declarations look?

var uses VariableEnvironment. The engine looks at EC.VariableEnvironment, which still points at f's record. The Binding for a is there (it was hoisted during f's creation phase). The engine writes 1 into it.

a is now 1. It lives in f's record.

Now let b = 2; runs. The engine wants to initialize the b Binding. let uses LexicalEnvironment. The engine looks at EC.LexicalEnvironment, which points at blockRecord. The Binding for b is there with value EMPTY. The engine flips it to 2.

b is now 2. It lives in blockRecord.

State:

blockRecord:
  bindings: { b: 2 }

f's record:
  bindings: { a: 1 }

The two declarations, on adjacent lines in the same block, landed in different records.

Now the closing }. The engine restores EC.LexicalEnvironment back to f's record. blockRecord is no longer pointed at by any EC. Nothing else points at it (no inner function was defined inside this block). It becomes garbage. b is gone.

State after block exit:

f's record:
  bindings: { a: 1 }

EC for f:
  LE → f's record
  VE → f's record       (back in sync)

Now console.log(a); runs. The engine looks up a. Starts at LexicalEnvironment = f's record. Finds a = 1. Prints 1.

Now console.log(b); runs. The engine looks up b. Starts at f's record. Not there. Walks [[OuterEnv]] to script's env. Not there. Hits null. Throws:

ReferenceError: b is not defined

This is not the TDZ error. It's the "not defined" error. There genuinely is no Binding for b in any reachable record. blockRecord is gone. The Binding for b died with it.

The behavior we observed — a survives, b doesn't — falls out mechanically from the pointer divergence. There's no rule called "function-scoped" or "block-scoped." There are two pointers that disagree, and var and let target different ones.

A small case study: block-scoped TDZ

Earlier I asked you to consider this version:

function f() {
  console.log(a);  // undefined
  console.log(b);  // ReferenceError: b is not defined
  if (true) {
    var a = 1;
    let b = 2;
  }
}

We can now trace it precisely. At the moment of the console.log calls:

  • f's creation phase has run. a is in f's record with value undefined (hoisted from the var inside the block).

  • The block hasn't been entered. There is no blockRecord yet. b doesn't exist anywhere.

So console.log(a) finds a = undefined and prints undefined. console.log(b) walks the chain, doesn't find b anywhere, throws "b is not defined."

If you wanted the TDZ error for b, you'd need to call console.log after entering the block but before the declaration line:

function f() {
  if (true) {
    console.log(b);  // ReferenceError: Cannot access 'b' before initialization
    let b = 2;
  }
}

Now the block has been entered. The block's creation phase has run. blockRecord exists with b: EMPTY. The console.log finds b, sees EMPTY, throws TDZ.

Each block with lexical declarations has its own TDZ window — from the moment the block is entered until the moment each declaration line initializes its variable.


Section 2.5: Why var inside a block ends up in the function's record

This deserves its own subsection because it's the single most mechanically surprising fact in this post.

The function's creation phase scans the entire function body, including code inside nested blocks. Every var declaration anywhere in the function — no matter how deeply nested — gets a Binding pre-installed in the function's record at function entry.

This rule is what gives var its "function-scoped" behavior. The behavior is a consequence, not a primitive.

Example with deep nesting:

function f() {
  console.log(deep);   // undefined — not "not defined"
  if (true) {
    if (true) {
      if (true) {
        var deep = "I am here";
      }
    }
  }
}
f();

When f's creation phase runs, it scans the body recursively. It descends into every block and every nested block. It finds var deep three levels in. It creates a Binding for deep in f's record with value undefined.

By the time the first line executes, deep exists in f's record. It's undefined. The console.log prints undefined.

Contrast with let:

function f() {
  console.log(deep);   // ReferenceError: deep is not defined
  if (true) {
    if (true) {
      if (true) {
        let deep = "I am here";
      }
    }
  }
}

Same code, var → let. Now f's creation phase does not install deep. let declarations are only hoisted to their nearest enclosing block, and that block hasn't been entered yet.

So console.log(deep) walks the chain, doesn't find deep, throws "not defined."

The unifying principle

Each Environment Record has its own creation phase, run when the record is created. A function's record is created at function entry; its creation phase scans the function body for var and function declarations. A block's record is created at block entry; its creation phase scans the block body for let/const/class declarations.

var and function declarations are only hoisted by function-record creation phases — they ignore any blocks they're nested inside. let, const, and class declarations are only hoisted by block-record creation phases — they don't reach up to the function level.

That's why the EC has two pointers. var was designed to bypass intermediate scopes, so it gets its own pointer that doesn't move when intermediate scopes are entered.


Section 3: Why two pointers exist (the historical reason)

You might reasonably ask: why was JavaScript designed this way? Couldn't the spec have used a single pointer and figured out where to put var some other way? The two-pointer design isn't the only possible mechanism. It's a specific choice. Worth understanding why.

JavaScript shipped in 1995. It had var. Variables declared with var had function scope: a var declared anywhere inside a function existed for the entire function, regardless of which block it was nested in. Tons of code was written over the next twenty years assuming this behavior.

In 2015, ECMAScript 6 added let and const with block scope. Block scope is what most other languages have — it matches what programmers from C, Java, Python, and similar backgrounds expect. It also eliminates a class of bugs around variable shadowing and loop closures.

The problem: the spec team couldn't change var. Existing code on the web relied on its function-scoping behavior. Changing it would have broken the internet.

So the team had to design a system where both behaviors coexist in the same function, in the same line of code if you want to mix them. The two-pointer EC was the solution:

  • var declarations target VariableEnvironment, which is stable through block entry. var keeps its 1995 behavior.

  • let and const target LexicalEnvironment, which swaps on block entry. They get block scope.

Same EC. Same execution flow. Two pointers. No backward incompatibility.

You could imagine alternative designs:

  • "Use one pointer and walk up to find the function's record when installing a var." This would work, but it's slower (every var installation requires a walk) and it makes the spec messier — every var operation needs a "find the function-level record" step.

  • "Special-case var so it ignores intermediate block records." This works conceptually but pushes the special-case logic into many spec operations. The two-pointer design localizes the special-casing into one place: the EC's two fields.

  • "Just change var to be block-scoped." This would have broken the web. Non-starter.

The two-pointer design is, in essence, caching the answer. The function-level record is what var always needs to find. Instead of computing it on every var operation, the EC just keeps a direct pointer.

You don't need to memorize this section. The point is: the two-pointer design is intentional and rests on backward compatibility. If you ever find yourself wondering why JavaScript looks weird, the answer is almost always "because it had to remain compatible with code from before that weirdness was a problem."


Section 4: Other places blocks happen

The four-step block-entry ritual we covered in Section 2 applies to any block that introduces lexical declarations. The block doesn't have to be inside an if. There are several places in JavaScript where the same machinery kicks in.

For-let loops

The most consequential is for loops with let:

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}

This prints 0, 1, 2. The same code with var prints 3, 3, 3.

Why? Because for (let i = ...) doesn't just create one Environment Record — it creates one per iteration.

The spec defines a special evaluation for this kind of for-loop. Before the first iteration, the engine creates an Environment Record for the loop's binding (i). Before each iteration, the engine creates a new record and copies i's current value into it. Any inner function created during that iteration captures that iteration's record.

For three iterations, you get three separate Environment Records, each with its own i Binding. The three setTimeout callbacks have [[Environment]] pointers to three different records. When they eventually run, each reads its own i.

With var, there's no per-iteration record. There's one var i Binding in the enclosing function's record. All three callbacks capture pointers up to that one Binding. By the time they run, the loop has finished and i is 3. All three print 3.

This is the famous "loop closure" gotcha. It's not a special mechanism — it's the per-iteration block ritual we already understand, applied to the for-loop's binding.

Catch clauses

When you write:

try {
  doSomething();
} catch (err) {
  // handle err
}

The catch (err) creates a new Environment Record holding the err Binding. The catch body sees that record. After the catch exits, the record is gone (unless something captured it).

This is the four-step ritual applied to catch syntax. The catch parameter is treated like a let binding in a block.

Function declarations inside blocks: strict vs sloppy

Here's where the language gets historically messy and where you can pick up an interview-grade detail most candidates don't know.

What does this code do?

function f() {
  if (true) {
    function g() { return 42; }
  }
  return g();
}
f();

The answer depends on whether the code is in strict mode or sloppy mode.

In strict mode (which is the default in classes, modules, and any file with "use strict"):

ReferenceError: g is not defined

In sloppy mode (which is the default in old-style scripts without "use strict"):

42

Why the difference?

In strict mode, a function declaration inside a block behaves like let. The function g() {} creates a Binding for g in the block's record, not the function's record. When the block exits, g's Binding goes with it. Outside the block, g is not defined.

In sloppy mode, the spec specifies an "Annex B" legacy behavior — a set of compatibility rules that exists because pre-ES6 browsers did something different from each other, and ES6 had to standardize something. Under Annex B.3.3, the engine creates a Binding for g in both the block's record (block-scoped) and the function's VariableEnvironment (var-scoped). The function-scoped Binding gets updated each time the block runs. So after the block, g exists at function scope, with the value it had when the block exited.

The reader doesn't need to memorize Annex B's details. What you should remember:

  1. Function declarations inside blocks are messy. Different rules in different modes.

  2. In strict mode, they behave like let. Block-scoped.

  3. In sloppy mode, there's a "web legacy" rule that adds extra scoping to mimic pre-ES6 browser behavior. It's a backward-compatibility hack.

  4. Modern code is almost always strict. Class bodies are automatically strict. ES modules are automatically strict. Any "use strict" directive makes the file strict. So in practice, you're almost always in the strict-mode world.

If you find yourself relying on the sloppy-mode behavior, you're depending on legacy compatibility logic that is intentionally underspecified. Restructure the code to make the function declaration explicit (move it out of the block, or use a let f = function() {} expression).


Section 5: Reading variables across the split

LexicalEnvironment and VariableEnvironment diverge during block execution. They start equal at function entry, separate at block entry, rejoin at block exit. During the diverged window, the two pointers point at different records.

Reads use LexicalEnvironment. Writes to declared variables use whichever pointer the declaration kind targeted (let/const → LexicalEnvironment, var → VariableEnvironment).

When you write a name inside a block:

  • let x = ... — write goes through LexicalEnvironment, hits the block record.

  • var x = ... — write goes through VariableEnvironment, hits the function record.

  • x = ... (assignment to existing variable) — engine walks the chain starting from LexicalEnvironment, finds the nearest Binding for x, writes there.

  • console.log(x) — engine walks the chain starting from LexicalEnvironment, finds the nearest Binding for x, reads it.

Reads always use LexicalEnvironment. The reader doesn't need to think about VariableEnvironment when reasoning about reads.

A small worked example with shadowing:

function f() {
  var x = "function";
  if (true) {
    let x = "block";
    console.log(x);   // "block"
  }
  console.log(x);     // "function"
}

Two Bindings for x exist while the block is active: one in f's record (the var x), one in blockRecord (the let x). Inside the block, lookup starts at blockRecord, finds x = "block", returns it. Outside the block (after block exit), blockRecord is gone, and lookup finds x = "function" in f's record.

This is shadowing. The inner x hides the outer x for the duration of the block. The outer one is never modified; it's just not visible from within the block because the lookup stops at the inner match.


Section 6: V8's view of blocks

We've been describing the spec mechanism. As always, V8 implements an optimized version.

V8's parse-time scope analysis applies to blocks the same way it applies to functions. For each block with lexical declarations, V8 asks: does any function defined inside this block reference these block variables?

  • No → the block's variables live on the function's stack frame. No heap Context is allocated for the block.

  • Yes → V8 allocates a heap Context for the block. The captured variables live in it.

You can observe this in DevTools. Try the following:

function f() {
  let outerVar = 1;
  if (true) {
    let blockVar = 2;
    const inner = () => {
      debugger;
      return outerVar + blockVar;
    };
    inner();
  }
}
f();

When the debugger pauses, look at the Scope panel:

Scope
├── Local        (inner's local scope — empty)
├── Block        ← V8 allocated a heap Context for the if-block
│     blockVar: 2
├── Closure (f)  ← V8 allocated a heap Context for f's scope
│     outerVar: 1
├── Script
└── Global

Two separate Contexts: one labeled "Block" (the if-block's record), one labeled "Closure (f)" (f's function record). Each holds the variables that V8 determined are captured by inner.

If you delete outerVar from the body of inner, the "Closure (f)" entry disappears from the Scope panel — V8's analysis decides f's variables don't need to survive, and stack-allocates them. The Block entry stays, because blockVar is still captured.

This is V8 showing you, in real time, the spec's two-record structure mapped onto its heap allocation strategy. The two entries you see in DevTools correspond exactly to the two Environment Records the spec describes.


Section 7a: A closure through a single block

Closures from Part 2 worked at the function level: an inner function captured its enclosing function's Environment Record. Now that blocks create their own records, closures can capture block records too. The mechanism is identical.

function makeGreeter(name) {
  if (true) {
    const greeting = `Hello, ${name}!`;
    return () => greeting;
  }
}

const greet = makeGreeter("World");
greet();  // "Hello, World!"

Let's trace.

Calling makeGreeter("World"):

  1. Create makeGreeterRecord. Parameter name bound to "World".

  2. EC pushed: LE and VE both point at makeGreeterRecord.

  3. Execute the body.

Body line 1: if (true) {. The block contains a const greeting declaration. The four-step ritual fires:

  1. Create blockRecord.

  2. Block creation phase: Binding for greeting with value EMPTY.

  3. blockRecord.[[OuterEnv]] = makeGreeterRecord.

  4. EC.LexicalEnvironment → blockRecord. VariableEnvironment stays at makeGreeterRecord.

Body line 2: const greeting = .... The engine evaluates the template literal ("Hello, World!") and initializes the Binding in blockRecord.

Body line 3: return () => greeting;. The engine creates an arrow function (a Function Object). At creation time, it sets arrow.[[Environment]] = currentEC.LexicalEnvironment = blockRecord.

The function returns.

Now: what happens to the records when makeGreeter returns?

  • makeGreeter's EC is popped from the stack.

  • makeGreeterRecord would be unreachable except — blockRecord.[[OuterEnv]] still points at it.

  • blockRecord would be unreachable except — the returned arrow function's [[Environment]] still points at it.

So we have a chain:

greet (the returned arrow)
  → arrow.[[Environment]]
    → blockRecord (with greeting: "Hello, World!")
      → blockRecord.[[OuterEnv]]
        → makeGreeterRecord (with name: "World")
          → makeGreeterRecord.[[OuterEnv]]
            → script's env

Three records survive makeGreeter's return: script's env (always alive), makeGreeterRecord, and blockRecord. All kept alive transitively through the arrow function's [[Environment]] pointer.

When you call greet(), a new Environment Record is created with [[OuterEnv]] = arrow.[[Environment]] = blockRecord. The body looks up greeting, finds it in blockRecord, returns it.

The mechanism is identical to Part 2's makeCounter. The only new piece: there's an extra record (the block's) in the chain.


Section 7b: A four-level nested closure stress test

If you've made it this far, you have the entire mental model. Time to stress-test it.

Consider this code. Don't try to predict the trace yet — just read it:

function outer() {
  let outerVar = "outer";
  {
    let blockVar1 = "block1";
    return function middle() {
      let middleVar = "middle";
      {
        let blockVar2 = "block2";
        return function inner() {
          return [outerVar, blockVar1, middleVar, blockVar2];
        };
      }
    };
  }
}

const fn = outer()();
fn();  // ["outer", "block1", "middle", "block2"]

Four levels: outer (function) → block in outer → middle (function) → block in middle → inner (function). The innermost function reads variables from all four levels.

By the time fn() finally runs, both outer and middle have long since returned. Their ECs are gone. Yet fn can still read outerVar, which was a local of outer.

How many Environment Records survive? Let's trace.

Calling outer():

Creates outerRecord. Body runs.

let outerVar = "outer"; — initializes.

{ — block contains let blockVar1. Four-step ritual creates outerBlockRecord. LE swaps to outerBlockRecord. VE stays at outerRecord.

let blockVar1 = "block1"; — initializes in outerBlockRecord.

return function middle() { ... }; — creates middle Function Object. middle.[[Environment]] = outerBlockRecord (the current LE).

outer returns. Its EC pops.

Now, who's alive?

  • middle.[[Environment]] → outerBlockRecord — keeps outerBlockRecord alive.

  • outerBlockRecord.[[OuterEnv]] → outerRecord — keeps outerRecord alive.

Chain so far: middle → outerBlockRecord → outerRecord → script.

outer() returned middle. The expression outer()() immediately calls it.

Calling middle():

Creates middleRecord. Its [[OuterEnv]] = middle.[[Environment]] = outerBlockRecord. Body runs.

let middleVar = "middle"; — initializes in middleRecord.

{ — inner block contains let blockVar2. Ritual creates middleBlockRecord. LE swaps. VE stays.

let blockVar2 = "block2"; — initializes.

return function inner() { ... }; — creates inner Function Object. inner.[[Environment]] = middleBlockRecord.

middle returns.

Who's alive?

  • inner.[[Environment]] → middleBlockRecord

  • middleBlockRecord.[[OuterEnv]] → middleRecord

  • middleRecord.[[OuterEnv]] → outerBlockRecord

  • outerBlockRecord.[[OuterEnv]] → outerRecord

The chain is now four records deep, all alive transitively:

fn (the inner function, assigned from outer()())
  → inner.[[Environment]] → middleBlockRecord (blockVar2)
                              ↓ [[OuterEnv]]
                            middleRecord (middleVar)
                              ↓ [[OuterEnv]]
                            outerBlockRecord (blockVar1)
                              ↓ [[OuterEnv]]
                            outerRecord (outerVar)
                              ↓ [[OuterEnv]]
                            script's env

Four Environment Records, all surviving past the returns of both outer and middle, all kept alive purely by the chain of [[OuterEnv]] and [[Environment]] pointers terminating at fn.

Calling fn():

Creates innerRecord. [[OuterEnv]] = inner.[[Environment]] = middleBlockRecord.

Body: return [outerVar, blockVar1, middleVar, blockVar2];

Each variable lookup walks the chain from innerRecord:

  • outerVar → not in innerRecord, walk to middleBlockRecord, not there, walk to middleRecord, not there, walk to outerBlockRecord, not there, walk to outerRecord — found, "outer".

  • blockVar1 → walk to middleBlockRecord, middleRecord, outerBlockRecord — found, "block1".

  • middleVar → walk to middleBlockRecord, middleRecord — found, "middle".

  • blockVar2 → middleBlockRecord — found, "block2".

Returns ["outer", "block1", "middle", "block2"].

Every lookup that needs a value walks back through however many records are between it and the target. The mechanism doesn't change with depth. The same two pointer-assignment rules from Part 2 ([[Environment]] at function creation, [[OuterEnv]] at function call) plus the block-ritual rules from this post handle arbitrary nesting.

If fn is set to null after this call, the entire chain dissolves:

  • fn no longer references inner.

  • inner becomes unreachable.

  • middleBlockRecord becomes unreachable.

  • middleRecord becomes unreachable.

  • outerBlockRecord becomes unreachable.

  • outerRecord becomes unreachable.

  • All five would be reclaimed in the next GC pass.

Reachability is transitive. One reference at the top keeps everything alive. Remove that reference and everything dies together.


Section 8: Runnable simulation, extended

Extending the simulation from Part 2 to support blocks. The new pieces: a helper to enter a block (perform the four-step ritual) and a helper to exit it.

// Reuse: EMPTY, Binding, EnvironmentRecord, FunctionObject, callFunction from Part 2.

// The EC structure
class ExecutionContext {
  constructor(lexicalEnv, variableEnv) {
    this.lexicalEnvironment = lexicalEnv;
    this.variableEnvironment = variableEnv;
  }
}

// Stack of ECs
const callStack = [];

// Enter a block: create new record, swap LE only.
function enterBlock(lexicalDeclarations) {
  const ec = callStack[callStack.length - 1];
  const previousLE = ec.lexicalEnvironment;
  const blockRecord = new EnvironmentRecord(previousLE);

  // Block's creation phase
  for (const decl of lexicalDeclarations) {
    blockRecord.createBinding(decl.name, decl.kind === "let");
  }

  ec.lexicalEnvironment = blockRecord;
  return { previousLE, blockRecord };
}

// Exit a block: restore LE.
function exitBlock(snapshot) {
  const ec = callStack[callStack.length - 1];
  ec.lexicalEnvironment = snapshot.previousLE;
}

Now simulate the opening puzzle:

const scriptEnv = new EnvironmentRecord(null);
scriptEnv.createBinding("f", true);

const f = new FunctionObject({
  name: "f",
  parameters: [],
  environment: scriptEnv,
  body: function (env) {
    // f's creation phase
    env.createBinding("a", true);
    env.initializeBinding("a", undefined);  // var hoisting

    // Push EC for f
    const ec = new ExecutionContext(env, env);
    callStack.push(ec);

    try {
      // Now enter the if-block
      const snapshot = enterBlock([{ name: "b", kind: "let" }]);

      // var a = 1 → uses VE, hits env (f's record)
      ec.variableEnvironment.setMutableBinding("a", 1);

      // let b = 2 → uses LE, hits blockRecord
      ec.lexicalEnvironment.initializeBinding("b", 2);

      exitBlock(snapshot);

      // console.log(a) — read via LE
      const aValue = ec.lexicalEnvironment.getBindingValue("a");
      console.log(aValue);  // 1

      try {
        const bValue = ec.lexicalEnvironment.getBindingValue("b");
        console.log(bValue);
      } catch (e) {
        console.log(e.message);  // b is not defined
      }
    } finally {
      callStack.pop();
    }
  },
});

scriptEnv.initializeBinding("f", f);
callFunction(f, []);

Output:

1
b is not defined

You can see the pointer divergence in code: the var a = 1 uses ec.variableEnvironment, the let b = 2 uses ec.lexicalEnvironment. After block exit, the b lookup walks the chain and fails.

This is the spec model, in 100 lines of JavaScript.


Section 9: What we built, and what's next

Three posts in, and you can now mechanically explain every behavior involving variables in JavaScript.

What we built in Part 3:

  • The EC has two Environment Record pointers: LexicalEnvironment and VariableEnvironment. They start equal at function entry.

  • Entering a block with let/const/class declarations triggers a four-step ritual: create new record, run block's creation phase, set [[OuterEnv]], swap LexicalEnvironment only.

  • var declarations target VariableEnvironment (which doesn't move). let/const target LexicalEnvironment (which does). This is the entire mechanism behind their different scoping rules.

  • The function-level creation phase scans the entire function body and hoists every var to the function's record, regardless of block nesting.

  • for-let loops create a new Environment Record per iteration, which is why each iteration's i is independent in closure captures.

  • Strict-mode and sloppy-mode treat function declarations inside blocks differently. Modern code (classes, modules) is automatically strict.

  • The block-ritual mechanism scales to arbitrary depth. Closures through nested blocks and functions keep arbitrarily long chains of records alive in the heap.

We've now covered every variable in JavaScript. How it's declared. Where its Binding lives. How it's looked up. When its scope is created. When its Environment Record can survive past its function's return.

Every variable, that is, except one.

There is one identifier in JavaScript that doesn't follow any of these rules. It isn't installed in a Binding during the creation phase. It isn't looked up through the [[OuterEnv]] chain. Two calls to the same function can give it different values, even though every other variable in those calls would behave identically. It can even be deliberately missing from a function entirely — as is the case for arrow functions.

That identifier is this. Part 4 explains why it's the exception, how it actually works, and what arrow functions do differently — not as a special-case in the language, but as a single bit of state on the Function Object.


This is Part 3 of a four-part series on the JavaScript environment model. Next: Part 4 — "Why this Is the Only Exception."

The JavaScript Environment Model

Part 3 of 4

Most JavaScript tutorials describe what the language does. This series explains how it does it. Across four parts, we build a complete mechanical model of JavaScript's execution: what happens before a single line of your code runs, where variables actually live in memory, how closures keep records alive past their function's return, why var and let end up in different scopes, and why this is the only identifier that breaks every rule. No metaphors as endpoints. No "magic happens here." Every behavior reduces to named slots, pointers, and chain walks — small enough to fit in your head, concrete enough to apply to any puzzle. By the end, you'll be able to predict the output of any JavaScript program involving variables, scopes, closures, and this — not from memorized rules, but from a working model.

Up next

Part 4: Why this Is the Only Exception

The JavaScript Environment Model — Part 4 of 4. By the end of this post you'll be able to predict the value of this in any function, regardless of how it's defined or called. Arrow functions will stop