Scope, Environment Records, and Closures
Hoisting is instantiation, TDZ is an uninitialised binding, and a closure is a pointer.
Scope in JavaScript is a linked list of Environment Records built at precisely specified moments. Once you see FunctionDeclarationInstantiation and CreatePerIterationEnvironment as algorithms, `var` hoisting, the temporal dead zone, loop-closure behaviour, and the classic shared-context memory leak all become predictable rather than folklore.
In this chapter
- Environment Records are the specification's representation of scope
- Hoisting is FunctionDeclarationInstantiation
- A closure is `[[Environment]]`: a pointer, not a copy
- How engines materialise environments: stack slots versus context slots
- `eval` and `with`: the constructs that defeat static scope analysis
Environment Records are the specification's representation of scope
An Environment Record maps identifiers to bindings and has an [[OuterEnv]] field pointing to the enclosing record (or null for the global one). Identifier resolution (ResolveBinding → GetIdentifierReference) walks that chain calling HasBinding on each record until one answers yes, and returns a Reference Record whose [[Base]] is the record that owns the binding. There are five concrete kinds:
| Record kind | Created for | Notable behaviour |
|---|---|---|
| Declarative | Blocks, catch clauses, for heads, class bodies | Bindings live in an internal table. Supports uninitialised bindings (TDZ) and immutable bindings (const). |
| Function | Every function call (a subtype of Declarative) | Adds [[ThisValue]], [[ThisBindingStatus]], [[FunctionObject]], [[NewTarget]]. super and new.target resolve here. |
| Object | with statements and the global object part of the global scope | Bindings are the object's properties; HasBinding is [[HasProperty]] filtered by @@unscopables. This is dynamic scope. |
| Global | The realm's global scope | A composite of an Object record (for var and function declarations, which become properties of globalThis) and a Declarative record (for let, const, class, which do not). |
| Module | Each module's top level | A Declarative record that also supports indirect bindings: import { x } creates a binding that forwards GetBindingValue to the exporting module's record. This is how live bindings work (chapter 9). |
The Global record's split explains why let x at the top of a classic script does not create globalThis.x while var x does, and why a top-level let x and a later var x in another script conflict (both scripts share the same Global Environment Record, and CanDeclareGlobalVar is checked against the declarative part). It also explains the sloppy-mode rule that an undeclared assignment y = 1 creates a property on the global object: resolution falls off the end of the chain, and PutValue on an unresolvable Reference does Set(globalObject, name, value) in sloppy mode and throws ReferenceError in strict mode.
Hoisting is FunctionDeclarationInstantiation
"Hoisting" is an explanation invented for a process the specification describes directly. When a function is called, before the body executes, FunctionDeclarationInstantiation builds the environment in a fixed order: (1) bindings for every formal parameter are created and initialised (to undefined, then to the arguments); (2) if the function has non-simple parameters (defaults, destructuring, rest), a second environment is created for the body so that parameter default expressions cannot see body vars; (3) every var name is created and initialised to undefined; (4) every lexical declaration (let, const, class) is created uninitialised; (5) every function declaration is created and immediately initialised with its function object. Only then does the body run.
Step 3 versus step 4 is the entire difference between var and let hoisting. Both are created before the body executes; the difference is whether the binding is initialised. GetBindingValue on an uninitialised binding throws ReferenceError, and that window between creation and the declaration statement executing is the Temporal Dead Zone. It is temporal, not textual: a function that references x before the let x line in source order works fine if it is called after the declaration executed.
function demo() {
// Step 5 ran already: f is a callable binding before this line.
console.log("typeof f before its declaration:", typeof f);
console.log("var v before assignment:", v); // initialised to undefined at step 3
const read = () => x; // references x textually before its declaration
try { read(); } catch (e) { console.log("read() before let x:", e.constructor.name); }
let x = 10; // InitializeReferencedBinding
console.log("read() after let x:", read());
// typeof is not a safe guard against TDZ (unlike against undeclared globals):
try { typeof y; } catch (e) { console.log("typeof y in TDZ throws:", e.constructor.name); }
let y;
var v = 1;
function f() {}
}
demo();
// Parameter scope is separate from body scope when parameters are non-simple.
function params(a, b = () => a + inner) {
var inner = 100; // body var: invisible to default expressions
var a = 5; // a *copy* of the parameter binding, per step 27-28
try { return b(); } catch (e) { return e.constructor.name + " (inner is not visible to defaults)"; }
}
console.log(params(1));The parameter/body separation has a subtle corner: when parameters are non-simple and the body redeclares a parameter with var, the body gets a separate binding initialised to the parameter's current value (FunctionDeclarationInstantiation steps 27–28). A default-value closure captured the parameter environment, so it still sees the original parameter. This is why params(1) above does not see a = 5.
A closure is `[[Environment]]`: a pointer, not a copy
When a function object is created (OrdinaryFunctionCreate / InstantiateOrdinaryFunctionObject), its [[Environment]] slot is set to the currently running Environment Record. Nothing is copied. Each later call creates a new Function Environment Record with [[OuterEnv]] pointing to that saved record. Two closures created in the same scope therefore share exactly the same outer record and see each other's writes. The environment stays alive as long as any function object that references it is reachable. That is the complete definition; every closure behaviour derives from it.
function makeAccount(balance) {
// `balance` lives in this call's Function Environment Record.
return {
deposit: (n) => (balance += n),
view: () => balance,
};
}
const a = makeAccount(10), b = makeAccount(100); // two calls -> two records
a.deposit(5);
console.log("a:", a.view(), "| b:", b.view(), "(separate records)");
// The classic loop puzzle, explained by *which* record the closure points at.
const viaVar = [], viaLet = [];
for (var i = 0; i < 3; i++) viaVar.push(() => i); // one function-level binding, shared
for (let j = 0; j < 3; j++) viaLet.push(() => j); // CreatePerIterationEnvironment copies j each iteration
console.log("var:", viaVar.map((f) => f()), "| let:", viaLet.map((f) => f()));The let loop behaviour is specified by CreatePerIterationEnvironment: at the end of each iteration, a fresh Declarative Environment Record is created, the current values of the loop's let bindings are copied into it, and the loop continues in the new record. The increment expression j++ therefore runs in the new iteration's environment, which is why a closure created in iteration k sees the value of j at the end of iteration k, before increment. for (const ...) in a classic for head is legal and produces a fresh binding per iteration that is never incremented; it only errors if the update expression tries to assign it. for…of and for…in create a fresh environment per iteration by construction (ForIn/OfBodyEvaluation).
How engines materialise environments: stack slots versus context slots
Allocating a heap object for every function call's environment would be prohibitive. V8's scope analysis classifies each variable at parse time. A variable that is referenced only by the function that declares it is stack-allocated: it lives in an interpreter register or, after optimisation, a machine register or stack slot, and vanishes on return. A variable that is referenced by any inner function is context-allocated: it lives in a heap-allocated Context object created on function entry, and the inner function objects hold a pointer to that Context. The Context is the engine's realisation of the Environment Record, but only for the variables that need it.
This has a consequence the specification does not predict but that follows from the engine's coarse granularity: all context-allocated variables of a scope live in one Context object. If a scope has two inner functions, one capturing a large buffer and one capturing a small counter, both functions keep the same Context alive, and the Context holds the buffer. Returning only the counter closure still retains the buffer. Engines could in principle split contexts per captured variable, but none do; the cost model assumes closures created together are used together.
// A pattern that leaks in every mainstream engine, for the same structural reason.
function setup() {
const big = new Uint8Array(20 * 1024 * 1024); // 20 MB
const useBig = () => big.length; // captures `big` -> big is context-allocated
let count = 0;
const tick = () => ++count; // captures only `count`...
useBig(); // ...but shares the same Context object
return tick; // returning tick keeps big alive
}
const keep = [];
for (let i = 0; i < 4; i++) keep.push(setup());
console.log("4 tick closures retained; expect ~80 MB of retained ArrayBuffer if the engine shares Contexts.");
if (globalThis.performance?.memory) {
console.log("usedJSHeapSize (excludes ArrayBuffer backing stores, so may not show it):", (performance.memory.usedJSHeapSize / 1e6).toFixed(1), "MB");
}
console.log("Fix: null out `big` before returning, or restructure so the two closures do not share a scope.");
keep.length = 0;The sandbox cannot show a heap snapshot; run this in DevTools and take a snapshot with tick closures retained to see the Uint8Array reachable through (closure) → context → big.
`eval` and `with`: the constructs that defeat static scope analysis
Scope analysis works because the set of names a function can reference is known at parse time. Two constructs break this. A direct `eval` (the syntactic form eval(...) resolving to the intrinsic %eval%) evaluates code in the caller's variable environment and can, in sloppy mode, declare new vars into it (PerformEval with direct = true creates a new declarative environment for lexical declarations but instantiates vars in the caller's variable environment). A with statement inserts an Object Environment Record whose bindings are the properties of an arbitrary object that can change at runtime. When either appears, the engine must assume every variable in every enclosing scope may be referenced dynamically, so it context-allocates all of them and disables most scope-related optimisations for the entire function chain. An indirect eval ((0, eval)(code), globalThis.eval(code), aliasing eval to another name) runs in the global scope and has none of these costs; the direct/indirect distinction is syntactic and decided at parse time.
function scopeTest() {
const secret = "in function scope";
const direct = eval("typeof secret"); // caller's environment
const indirect = (0, eval)("typeof secret"); // global environment
const aliased = globalThis.eval("typeof secret");
console.log("direct:", direct, "| indirect:", indirect, "| aliased:", aliased);
eval("var injected = 1"); // sloppy direct eval declares into the caller's var scope
console.log("injected via direct eval:", typeof injected);
}
scopeTest();
// Strict mode: direct eval gets its own variable environment, so it cannot inject.
(function () {
"use strict";
eval("var cannotEscape = 1");
console.log("strict direct eval injection:", typeof cannotEscape);
})();Where these ideas come from
The mechanisms in this chapter, traced to the paper, standard, or system that introduced them, and how each reached JavaScript.
| Mechanism | Origin | Path into JavaScript |
|---|---|---|
| Environments as chained frames | Peter Landin, The Mechanical Evaluation of Expressions (SECD machine) (1964) | The Environment Record with an [[OuterEnv]] link is the SECD environment; identifier resolution walks it exactly as Landin's E component. |
| Lexical scoping | ALGOL 60 committee (Peter Naur, ed.), Report on the Algorithmic Language ALGOL 60 (1960) | Block structure and static scope came to JavaScript late (let/const in ES2015); var's function-level scope is the 1995 compromise. |
| Temporal dead zone | Allen Wirfs-Brock, Dave Herman (TC39), ES2015 let and const semantics (2015) | Uninitialised bindings that throw on access, so that declarations are hoisted but not silently undefined. |
| Closure conversion and context allocation | Guy L. Steele Jr., Rabbit: A Compiler for Scheme (1978) | Deciding at compile time which variables must live in a heap-allocated environment because an inner function captures them; V8's scope analysis does the same. |
Primary sources
- ECMA-262, §9.1 Environment Records
- ECMA-262, §10.2.11 FunctionDeclarationInstantiation
- ECMA-262, §14.7.4.4 CreatePerIterationEnvironment
- ECMA-262, §19.2.1.1 PerformEval
- V8 blog: Understanding the ECMAScript spec, part 1
- David Glasser: An interesting kind of JavaScript memory leak (shared closure contexts)