How Lexical Environment Works in JavaScript
Understand the JavaScript lexical environment data structure. Learn how environment records, outer references, and the scope chain power variable resolution, closures, and hoisting.
Every time JavaScript code runs, the engine creates a lexical environment, an internal data structure that stores the variables for that scope and a reference to the parent scope. This is the mechanism behind lexical scoping, closures, hoisting, and variable resolution. Understanding the lexical environment reveals why certain code patterns work the way they do.
What Is a Lexical Environment?
A lexical environment has two components:
- Environment Record: A table that maps variable names to their values in the current scope
- Outer Reference: A pointer to the parent lexical environment (or
nullfor the global scope)
// Global lexical environment:
// Environment Record: { greeting: "Hello", sayHi: <function> }
// Outer: null
const greeting = "Hello";
function sayHi(name) {
// sayHi's lexical environment:
// Environment Record: { name: "Alice", message: "Hello, Alice!" }
// Outer: -> Global lexical environment
const message = `${greeting}, ${name}!`;
console.log(message);
}
sayHi("Alice");Visual Representation
| Lexical Environment | Environment Record | Outer Reference |
|---|---|---|
| Global | greeting: "Hello", sayHi: <function> | null |
sayHi("Alice") | name: "Alice", message: "Hello, Alice!" | Global |
Environment Record Types
JavaScript uses different types of environment records depending on the context:
Declarative Environment Record
Created for functions and blocks. Stores variables declared with let, const, var, function declarations, and parameters:
function calculate(a, b) {
// Declarative Environment Record:
// {
// a: 5,
// b: 3,
// sum: <uninitialized>, // let - in temporal dead zone until line executes
// multiply: <function> // function declaration - hoisted and initialized
// }
let sum = a + b;
function multiply() {
return a * b;
}
return { sum, product: multiply() };
}
calculate(5, 3);Object Environment Record
Created for the global scope (in browsers, tied to the window object) and with statements:
// Global Object Environment Record (browser)
var globalVar = "hello";
// globalVar is added to window: window.globalVar === "hello"
let blockVar = "world";
// blockVar is NOT on window -- it's in a separate declarative recordGlobal Environment Record
The global scope actually has a composite environment record that combines both:
// Global lexical environment has TWO records:
// 1. Object Environment Record (for var and function declarations)
// -> Stored on globalThis/window
// 2. Declarative Environment Record (for let and const)
// -> NOT on globalThis/window
var x = 1; // Object record -> window.x === 1
let y = 2; // Declarative record -> window.y === undefined
const z = 3; // Declarative record -> window.z === undefined
function foo() {} // Object record -> window.foo === foo| Declaration | Record Type | On window? | Hoisted? |
|---|---|---|---|
var | Object Environment Record | Yes | Yes (initialized to undefined) |
let | Declarative Environment Record | No | Yes (but in TDZ) |
const | Declarative Environment Record | No | Yes (but in TDZ) |
function | Object Environment Record | Yes | Yes (fully initialized) |
class | Declarative Environment Record | No | Yes (but in TDZ) |
How Variable Resolution Works
When JavaScript encounters a variable name, it searches through the chain of lexical environments:
const global = "G";
function outer() {
const outerVal = "O";
function middle() {
const middleVal = "M";
function inner() {
const innerVal = "I";
// Resolving `outerVal`:
// Step 1: Check inner's env record -> not found
// Step 2: Follow outer reference to middle's env record -> not found
// Step 3: Follow outer reference to outer's env record -> FOUND "O"
console.log(outerVal);
}
inner();
}
middle();
}
outer();Resolution Algorithm in Pseudocode
function resolveBinding(name, lexicalEnv) {
// Base case: reached beyond global scope
if (lexicalEnv === null) {
throw new ReferenceError(`${name} is not defined`);
}
// Check current environment record
const record = lexicalEnv.environmentRecord;
if (record.hasBinding(name)) {
// Check if the binding is initialized (TDZ check for let/const)
if (!record.isInitialized(name)) {
throw new ReferenceError(
`Cannot access '${name}' before initialization`
);
}
return record.getValue(name);
}
// Not found here, check the outer environment
return resolveBinding(name, lexicalEnv.outerReference);
}Function Creation and [[Environment]]
When a function is created (not called), JavaScript stores the current lexical environment in the function's hidden [[Environment]] slot. This is what makes closures work:
function createMultiplier(factor) {
// Lexical environment A: { factor: 5 }
return function multiply(number) {
// When multiply is created HERE, [[Environment]] = A
return number * factor;
};
}
const double = createMultiplier(2);
// double.[[Environment]] = { factor: 2, outer: global }
const triple = createMultiplier(3);
// triple.[[Environment]] = { factor: 3, outer: global }
// When double(10) is called:
// 1. Create new lexical environment B: { number: 10 }
// 2. Set B's outer reference = double.[[Environment]] = { factor: 2 }
// 3. Resolve `factor`: not in B -> check outer -> found 2
// 4. Return 10 * 2 = 20
console.log(double(10)); // 20
console.log(triple(10)); // 30Function Call Creates a New Environment
Every function call creates a brand new lexical environment, even for the same function:
function makeAdder(x) {
return (y) => x + y;
}
const add5 = makeAdder(5);
// Call 1: New env { x: 5 }, returned function's [[Environment]] -> this env
const add10 = makeAdder(10);
// Call 2: Another new env { x: 10 }, returned function's [[Environment]] -> this env
// add5 and add10 have DIFFERENT [[Environment]] references
console.log(add5(3)); // 8 (x=5 from Call 1's environment)
console.log(add10(3)); // 13 (x=10 from Call 2's environment)Block-Level Lexical Environments
let and const inside a block (if, for, while) create a new lexical environment for that block:
function example() {
// Function environment: { a: undefined (var hoisted) }
var a = 1;
let b = 2;
if (true) {
// Block environment: { c: 3, d: 4 }
// Outer: -> Function environment
let c = 3;
const d = 4;
var e = 5; // var ignores block scope, goes to function env
console.log(a, b, c, d, e); // 1 2 3 4 5
}
console.log(a, b, e); // 1 2 5
// console.log(c); // ReferenceError: c is not defined
}Loop Environments
for loops with let create a new environment per iteration:
for (let i = 0; i < 3; i++) {
// Iteration 0: New block env { i: 0 }, outer -> loop env
// Iteration 1: New block env { i: 1 }, outer -> loop env
// Iteration 2: New block env { i: 2 }, outer -> loop env
setTimeout(() => {
// Each callback's [[Environment]] points to its iteration's block env
console.log(i);
}, 100);
}
// Output: 0, 1, 2 -- each closure has its own `i`Compare with var:
for (var i = 0; i < 3; i++) {
// var i lives in the function/global env, NOT in a per-iteration block env
// All callbacks share the SAME `i`
setTimeout(() => {
console.log(i);
}, 100);
}
// Output: 3, 3, 3 -- all closures reference the same `i`Hoisting Explained Through Lexical Environments
Hoisting is not "moving declarations to the top." It is the environment record being created before code executes, with bindings in different initialization states:
// Before any code runs, the environment record is scanned:
// {
// greet: <function reference> -- function declaration: fully initialized
// name: undefined -- var: initialized to undefined
// age: <uninitialized> -- let: exists but NOT initialized (TDZ)
// PI: <uninitialized> -- const: exists but NOT initialized (TDZ)
// }
console.log(greet); // [Function: greet] -- works, fully initialized
console.log(name); // undefined -- works, but initialized to undefined
// console.log(age); // ReferenceError: Cannot access 'age' before initialization
// console.log(PI); // ReferenceError: Cannot access 'PI' before initialization
function greet() { return "Hi"; }
var name = "Alice";
let age = 30;
const PI = 3.14;Initialization Timeline
| Declaration | When Created | When Initialized | Initial Value |
|---|---|---|---|
function declaration | Entering scope | Entering scope | Function reference |
var | Entering scope | Entering scope | undefined |
let | Entering scope | When statement executes | TDZ until then |
const | Entering scope | When statement executes | TDZ until then |
| Function parameter | Entering scope | Entering scope | Argument value |
Closures Are Just Persistent Lexical Environments
When you understand lexical environments, closures become straightforward. A closure exists when a function's [[Environment]] keeps a lexical environment alive after the creating function has returned:
function counter() {
// Lexical Environment A is created:
// { count: 0 }
let count = 0;
return function increment() {
// increment.[[Environment]] = A
// Even after counter() returns, A stays alive because increment references it
count++;
return count;
};
}
const inc = counter();
// counter()'s execution is done
// But Environment A { count: 0 } is NOT garbage collected
// because inc.[[Environment]] points to it
inc(); // count in A becomes 1
inc(); // count in A becomes 2
// If we do:
// inc = null;
// Now nothing references increment, so nothing references A
// A can be garbage collectedthis Is Not Part of the Lexical Environment
The this value is stored in the execution context, not in the lexical environment. This is why this follows different rules than variables:
const obj = {
name: "Alice",
greet() {
// `this` is NOT in the lexical environment
// It's determined by how greet() is called
console.log(this.name);
const inner = function () {
// `this` here is NOT obj -- it's window/undefined
console.log(this.name); // undefined (strict mode) or window.name
};
const arrow = () => {
// Arrow functions don't get their own `this`
// They use `this` from the lexical environment (like a variable)
console.log(this.name); // "Alice"
};
inner();
arrow();
}
};
obj.greet();Arrow functions are special because they DO capture this lexically, unlike regular functions.
Rune AI
Key Insights
- Two components: Every lexical environment has an environment record (variable storage) and an outer reference (link to the parent scope's environment)
- New environment per call: Each function invocation and each block with
let/constcreates a fresh lexical environment, which is why closures and recursion work correctly [[Environment]]enables closures: When a function is created, it captures a reference to the current lexical environment, keeping that environment alive even after the creating function finishes- Hoisting is environment creation: The engine creates all bindings in the environment record before executing code,
varstarts asundefined,let/conststart uninitialized (TDZ) thisis separate: Thethisbinding lives in the execution context, not the lexical environment, which is why it follows call-site rules (except in arrow functions, which look upthislexically)
Frequently Asked Questions
What is the difference between scope and lexical environment?
Does every function call create a new lexical environment?
How does the lexical environment relate to hoisting?
Why does var behave differently from let and const?
Do arrow functions create their own lexical environment?
Conclusion
The lexical environment is the internal data structure that JavaScript uses to track variables. Each environment has an environment record (the variable name-value pairs) and an outer reference (a pointer to the parent environment). Variable resolution walks this chain from inner to outer. Closures work because functions store their creation-time lexical environment in [[Environment]], keeping it alive even after the outer function returns.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.