Lexical Environment
The lexical environment is the internal data structure the JavaScript engine uses to track which variables exist and what they hold at any point in your code. Understanding it turns scope and closures from “magic” into something you can reason about precisely. This page explains the model the specification actually uses, so the behavior you see in real programs stops being surprising.
What a lexical environment is
Every time the engine runs a piece of code — a script, a function body, or a block — it creates a lexical environment for that code. A lexical environment is made of two parts:
- An environment record: the storage that holds the bindings (variables, function declarations, parameters) declared directly in that scope.
- An outer reference: a pointer to the lexical environment that lexically surrounds this one.
The word “lexical” is the key idea: the outer reference is determined by where the code is written in the source, not by where or how it is called. This is why JavaScript uses lexical (static) scoping. A function nested inside another function can always reach the outer function’s variables, regardless of who invokes it.
Conceptually, you can picture an environment record as a simple key/value store:
Environment Record
count: 0
step: 2
Outer ──▶ (the surrounding environment)
The environment record is conceptual. Engines like V8 do not literally allocate an object per scope when they can optimize it away — but the observable behavior matches this model exactly.
The scope chain and identifier resolution
When the engine evaluates a variable name, it looks it up in the current environment record. If the binding is not there, it follows the outer reference to the parent environment and looks again. It keeps walking outward until it finds the binding or reaches the global environment. This linked series of environments is the scope chain.
const planet = "Earth";
function outer() {
const greeting = "Hello";
function inner() {
const name = "world";
console.log(greeting, name, planet);
}
inner();
}
outer();
Output:
Hello world Earth
When inner runs, name is found in its own record, greeting is found one hop up in outer, and planet is found at the global environment. The lookup always moves outward, never inward — outer could not see name, because that binding lives in a deeper, unrelated scope.
Here is the chain that resolves the call above:
inner env { name: "world" }
│ outer
▼
outer env { greeting: "Hello", inner: <fn> }
│ outer
▼
global env { planet: "Earth", outer: <fn> }
│ outer
▼
null
If a name is never found anywhere on the chain, reading it throws a ReferenceError; assigning to it in non-strict mode silently creates a global, which is one reason strict mode (which throws instead) is recommended.
How this underpins closures
A closure is simply a function together with the lexical environment it was created in. Because a function keeps its outer reference alive, the environment it closed over survives even after the outer function has returned — as long as the function still exists, its scope chain cannot be garbage collected.
function makeCounter(start = 0) {
let count = start;
return function increment(step = 1) {
count += step;
return count;
};
}
const next = makeCounter(10);
console.log(next());
console.log(next(5));
console.log(next());
Output:
11
16
17
makeCounter has already returned, yet increment still reads and mutates count. That works because increment’s outer reference still points at the lexical environment that was created when makeCounter ran. Each call to makeCounter produces a brand-new environment record, so two counters never share state.
Per-iteration environments with let
Block scoping creates a fresh lexical environment for each block — and, importantly, a new one for each iteration of a for loop when you use let. This is the classic fix for capturing loop variables.
const fns = [];
for (let i = 0; i < 3; i++) {
fns.push(() => i);
}
console.log(fns.map((fn) => fn()));
Output:
[0, 1, 2]
With var, all three closures would share one function-level binding and log [3, 3, 3]. With let, the loop creates a separate environment record per iteration, each holding its own i.
Block, function, and global environments
| Environment kind | Created for | Holds |
|---|---|---|
| Global | The whole program | var/function declarations (on the global object) plus top-level let/const |
| Function | Each function invocation | Parameters, var, function declarations, inner let/const |
| Block | Each { } block, loop iteration, catch | let, const, and block-scoped class/function declarations |
Function environments also carry environment-specific values such as this, arguments, and new.target, which is why these behave differently from ordinary variables and are not inherited through the same outer reference that ordinary identifiers use.
Best Practices
- Rely on lexical scoping: define functions where the variables they need already live, instead of passing everything as arguments.
- Prefer
constandletso each block gets its own environment and bindings cannot leak upward unexpectedly. - Use
letinforloops when callbacks capture the loop variable, to get a fresh binding per iteration. - Keep scope chains shallow; deeply nested closures make lookups harder to follow and can retain memory longer than expected.
- Always run in strict mode (
"use strict"or ES modules) so an unresolved assignment throws rather than creating an accidental global. - Remember that a returned function keeps its entire enclosing environment alive — null out large captured values when you no longer need them.