Scope & Lexical Environments
โฑ๏ธ ~4-minute bite ยท solve the sandbox to master
5-Year-Old Metaphor
โ The physical, real-world picture. No jargon.๐ช Russian dolls (matryoshka) โ each function or block creates a new Environment Record nested inside the outer one.
The matryoshka analogy
Imagine a set of Russian nesting dolls. The smallest (innermost) doll represents the currently-running function. Surrounding it are larger dolls โ the enclosing functions, blocks, module, and finally the global scope.
When JavaScript looks up a variable, it opens the innermost doll first. If the variable isn't there, it opens the next doll outward โ and keeps going until it either finds the binding or opens the outermost doll and finds nothing. In the latter case it throws a ReferenceError.
Crucially: the nesting is determined at write time โ by where you write your code in the source file โ not at call time. This is called lexical scoping.
LexicalEnvironment vs VariableEnvironment โ two slots, one context
Every Execution Context (function call, module, global code) carries two environment slots. Most of the time they point to the same object โ but they can diverge:
LexicalEnvironment
Handles let, const, and block-scoped bindings. A new LexicalEnvironment is created for every {} block entered.
VariableEnvironment
Handles var hoisting in function bodies. Stays fixed for the lifetime of the function call โ blocks don't create new ones. This is why var "escapes" if/for blocks.
The two slots diverge only inside try/catch (the catch param gets its own LexicalEnvironment) and the legacy with statement.
Environment Record hierarchy (the spec)
The ECMAScript spec defines a hierarchy of Environment Record types. Each kind handles a different scoping scenario:
DeclarativeEnvironmentRecord
Used for let, const, function, class, and import bindings inside blocks and functions. Bindings are stored directly as nameโvalue pairs.
ObjectEnvironmentRecord
Used for global var bindings (backed by the global object / window) and the with statement. Property lookup on an actual object โ much slower.
GlobalEnvironmentRecord
Wraps both: a DeclarativeEnvironmentRecord for let/const/class, and an ObjectEnvironmentRecord for var and function declarations at the top level (window in browsers).
ModuleEnvironmentRecord
A specialized DeclarativeEnvironmentRecord for ES modules. Imported bindings are live โ they stay in sync with the exporting module's bindings.
Static (lexical) vs dynamic scope
JavaScript uses lexical scope: the scope chain is determined at write time โ by the physical nesting of source code. A function always looks up variables in the environment where it was defined, not where it was called.
Lexical scope (JS โ write-time)
| 1 | const x = 'outer'; |
| 2 | function inner() { return x; } |
| 3 | function caller() { |
| 4 | const x = 'local'; // irrelevant! |
| 5 | return inner(); // โ 'outer' |
| 6 | } |
Dynamic scope (NOT JS โ hypothetical)
| 1 | const x = 'outer'; |
| 2 | function inner() { return x; } |
| 3 | function caller() { |
| 4 | const x = 'local'; |
| 5 | return inner(); // would โ 'local' |
| 6 | // (call-site x wins โ this is NOT JS) |
| 7 | } |
eval() and with are the only constructs that can dynamically modify scope at runtime โ which is exactly why they break static analysis and are forbidden in strict mode / modern code.
Interactive Sandbox
โ Move something, see it react instantly.Pattern
Code
| 1 | var globalX = 'global'; |
| 2 | function outer() { |
| 3 | var outerX = 'outer'; |
| 4 | function inner() { |
| 5 | var innerX = 'inner'; |
| 6 | console.log(outerX); // โ lookup |
| 7 | } |
| 8 | inner(); |
| 9 | } |
Scope chain โ lookup of outerX from inner() scope
Lookup trace
Looking up outerX from inner() scope: checked 2 environments, found at outer()
Challenge
Explore all 5 scope patterns. For each, trace the lookup path from the innermost environment outward โ notice exactly where the engine stops.
Why Should I Care?
โ The exact interview question + the bug it kills.Exact interview questions
Q: Difference between scope and execution context?
Scope is a static concept โ the region of code where a variable is visible. It's determined by where you write your declarations. An Execution Context is a runtime object created each time a function is called; it contains the LexicalEnvironment, VariableEnvironment, and this binding. Multiple calls to the same function produce multiple Execution Contexts, but they all share the same lexical scope structure.
Q: Why does var leak out of if blocks but let doesn't?
| 1 | if (true) { |
| 2 | var leaked = 'yes'; // goes to VariableEnvironment (function/global) |
| 3 | let blocked = 'no'; // goes to LexicalEnvironment (this block only) |
| 4 | } |
| 5 | console.log(leaked); // 'yes' โ var ignores blocks |
| 6 | console.log(blocked); // ReferenceError โ let is block-scoped |
var declarations are processed by the VariableEnvironment, which is shared across the entire function (or global scope). Entering a {} block does not create a new VariableEnvironment. let and const live in the LexicalEnvironment โ and each block gets its own fresh one.
Q: What is lexical scope and how does it differ from dynamic scope?
Lexical (static) scope means the scope chain is fixed at write time by the physical nesting of source code. A function looks up variables in its definition environment, not its call environment. Dynamic scope (used by some other languages) would walk the call stack instead. JavaScript is always lexically scoped โ with the exception of eval() in non-strict mode, which can inject new bindings into the surrounding scope at runtime.
Production bugs scope causes
Bug: var in for loop captured by wrong scope
| 1 | // โ Classic closure + var bug |
| 2 | const handlers = []; |
| 3 | for (var i = 0; i < 3; i++) { |
| 4 | handlers.push(() => console.log(i)); |
| 5 | } |
| 6 | handlers[0](); // 3 โ not 0! |
| 7 | handlers[1](); // 3 โ not 1! |
| 8 | handlers[2](); // 3 โ not 2! |
| 9 | ย |
| 10 | // โ Fix 1: use let โ fresh binding per iteration |
| 11 | for (let i = 0; i < 3; i++) { |
| 12 | handlers.push(() => console.log(i)); |
| 13 | } |
| 14 | ย |
| 15 | // โ Fix 2: IIFE (pre-ES6 pattern) |
| 16 | for (var i = 0; i < 3; i++) { |
| 17 | (function(j) { handlers.push(() => console.log(j)); })(i); |
| 18 | } |
var i is function-scoped โ all closures share one binding. By the time any handler fires, the loop has finished and i === 3. The IIFE pattern creates a new function scope per iteration to capture the current value. let does this automatically.
Bug: accidental global variable creation
| 1 | // In non-strict mode โ assignment to undeclared var |
| 2 | // creates a property on the global object silently! |
| 3 | function processUser(data) { |
| 4 | result = transform(data); // โ missing let/const/var! |
| 5 | return result; |
| 6 | } |
| 7 | processUser({ name: 'Alice' }); |
| 8 | console.log(window.result); // 'alice-transformed' |
| 9 | // This leaks across every function in the page. |
| 10 | ย |
| 11 | // Fix: always 'use strict' (or use a bundler/TS that implies it) |
| 12 | // or always declare with let/const/var |
Strict mode makes this a ReferenceError immediately. Accidental globals in non-strict mode cause state corruption across modules and are nearly impossible to track down in large apps.
The IIFE pattern โ why it existed
| 1 | // Before ES modules and let/const (pre-2015): |
| 2 | // Developers wrapped entire libraries in IIFEs to create private scope |
| 3 | (function() { |
| 4 | var privateState = []; // invisible to outside world |
| 5 | function privateHelper() { /* ... */ } |
| 6 | ย |
| 7 | // Only expose what's needed on window |
| 8 | window.MyLib = { |
| 9 | doThing: function() { return privateHelper(); }, |
| 10 | }; |
| 11 | })(); |
| 12 | // privateState and privateHelper are gone โ not on window |
The IIFE (Immediately Invoked Function Expression) was the primary way to create module-like private scope before ES modules. Every function call creates a new scope โ by immediately invoking an anonymous function, you get a private bubble with no variable leakage into the global scope. jQuery, Underscore, and virtually every major pre-module library used this pattern.
Today: ES modules (import/export) give you module scope automatically, and let/const give you block scope. IIFEs are largely obsolete in modern code but appear heavily in legacy codebases.
The Deep Dive
โ Spec refs, engine internals, the minutiae.ES spec: the four NewEnvironment algorithms
The ECMAScript spec defines four algorithms for creating Environment Records. Each is called at a distinct moment in the execution lifecycle:
NewDeclarativeEnvironment(outer)
Called when entering a block, function body, or catch clause
Creates a DeclarativeEnvironmentRecord with its [[OuterEnv]] set to outer. Used for let/const/class/function (in blocks). The workhorse of modern JS scoping.
NewFunctionEnvironment(F, newTarget)
Called when a function F is invoked
Creates a FunctionEnvironmentRecord that extends DeclarativeEnvironmentRecord. Adds [[ThisValue]], [[NewTarget]], and [[HomeObject]] slots. Sets [[OuterEnv]] to F.[[Environment]] โ the closure chain.
NewModuleEnvironment(outer)
Called once when a module is linked
Creates a ModuleEnvironmentRecord. Imported bindings are created as 'import bindings' โ indirect references that stay live with the exporting module's binding.
NewGlobalEnvironment(G, thisValue)
Called once on realm initialization (page load, Worker start)
Creates a GlobalEnvironmentRecord combining an ObjectEnvironmentRecord (backed by global object G) and a DeclarativeEnvironmentRecord. var/function go to the ObjectER; let/const/class go to the DeclarativeER.
The with statement and ObjectEnvironmentRecord
| 1 | // with pushes an ObjectEnvironmentRecord onto the scope chain |
| 2 | const obj = { x: 1, y: 2 }; |
| 3 | with (obj) { |
| 4 | console.log(x + y); // 3 โ found on obj via ObjectEnvironmentRecord |
| 5 | } |
| 6 | ย |
| 7 | // Why this is fatal for static analysis: |
| 8 | with (mystery) { |
| 9 | // Is 'x' a local variable? A property of mystery? |
| 10 | // Impossible to know at compile time โ the engine must |
| 11 | // defer every lookup to runtime. V8 cannot optimize this. |
| 12 | doSomething(x); |
| 13 | } |
with inserts the object's properties as if they were local variables. The engine can no longer determine at compile time which environment a name belongs to โ it must check the ObjectEnvironmentRecord at runtime for every access. V8 cannot apply hidden class optimizations, inline caching, or dead code elimination inside a with block.
Strict mode impact on scope
Undeclared variable assignment โ ReferenceError
| 1 | 'use strict'; |
| 2 | accidentalGlobal = 42; // ReferenceError: accidentalGlobal is not defined |
| 3 | // (In sloppy mode: silently creates window.accidentalGlobal) |
with is a SyntaxError
| 1 | 'use strict'; |
| 2 | with (obj) {} // SyntaxError โ not just discouraged, forbidden |
eval gets its own scope
| 1 | 'use strict'; |
| 2 | eval('var x = 1'); |
| 3 | console.log(x); // ReferenceError โ x stays inside eval's own scope |
| 4 | // (In sloppy mode: x leaks into the surrounding scope) |
ES modules and class bodies are always in strict mode โ you get all of the above protections automatically. 'use strict' at the top of a script or function enables them explicitly.
Block scope internals: each { } has its own LexicalEnvironment
| 1 | function demo() { |
| 2 | // LexEnv A (function body) |
| 3 | let a = 1; |
| 4 | ย |
| 5 | if (true) { |
| 6 | // LexEnv B โ outer: A |
| 7 | let b = 2; // lives in B |
| 8 | ย |
| 9 | { |
| 10 | // LexEnv C โ outer: B |
| 11 | let c = 3; // lives in C |
| 12 | console.log(a); // walks: C โ B โ A, finds a |
| 13 | console.log(b); // walks: C โ B, finds b |
| 14 | } |
| 15 | // C destroyed, c gone |
| 16 | console.log(b); // walks: B, finds b |
| 17 | } |
| 18 | // B destroyed, b gone |
| 19 | console.log(a); // walks: A, finds a |
| 20 | } |
The engine creates a new LexicalEnvironment on every block entry (if, for, while, plain {}). Each let/const goes into the block's own DeclarativeEnvironmentRecord. When the block exits, that environment record becomes unreachable (unless a closure captured it) and is eligible for GC.
Performance: scope chain length and V8 optimizations
Every variable lookup walks the scope chain. A deeper chain = more work per lookup. V8 mitigates this via:
- Slot-based resolution: for known-lexical variables (let/const in simple closures), V8 can resolve them to a fixed slot index at compile time โ O(1), no chain walk needed.
- Context object reuse: if multiple closures in the same scope capture the same variable, they share one Context object (not one each).
- Stack allocation when safe: variables not captured by any closure stay on the stack (no heap allocation, no GC pressure).
- Deeply nested closures are slower: 10+ levels of scope chain forcing lookups that can't be slot-resolved will degrade performance. Flatten when possible in hot paths.
Chrome DevTools: inspecting lexical environments
- Open DevTools โ Sources tab
- Set a breakpoint inside the function you want to inspect
- Trigger the breakpoint โ execution pauses
- Look at the Scope pane (right side). It shows every environment in the chain:
- Local โ the innermost function's bindings
- Block โ any enclosing block scopes
- Closure (functionName) โ captured variables from outer functions
- Module โ module-level bindings
- Global โ window / globalThis
- You can hover over variables in the editor to see their current value from the Scope pane โ and even edit values in the Scope pane to test behavior without reloading.
Pro tip: the Scope pane shows exactly which scope level a variable was resolved from โ invaluable for debugging shadowing and unexpected closure captures.
Interview Questions
โ Real questions from real interviews โ with answers.`var` is function-scoped and hoisted to undefined; `let`/`const` are block-scoped and in the TDZ until their declaration.
`var` creates one shared binding for all iterations; `let` creates a fresh binding per iteration.
Lexical scope means a function's scope is determined by where it is written, not where it is called.
An IIFE creates a private function scope immediately โ the pre-module way to encapsulate state and avoid polluting the global scope.
Module-level `var` does NOT become a property of `globalThis` โ it stays in the module's own scope.
Memory Game
โ Quick quiz โ lock the concept in long-term memory.What happens when a function returns another function that references a variable from the outer function?