JavaScript Lexical Scope: A Complete Tutorial
Learn how JavaScript lexical scope determines variable access based on where code is written. Covers scope chains, nested functions, block scope, closures interaction, and common scope pitfalls.
Lexical scope means that variable access is determined by where a function is written in the source code, not where it is called. JavaScript uses lexical scoping exclusively. When the engine looks up a variable, it starts in the current scope and walks outward through enclosing scopes until it reaches the global scope. Understanding this chain is the key to understanding closures, variable shadowing, and most bugs related to "undefined" variables.
What Is Lexical Scope?
"Lexical" means "relating to the text." Lexical scope is defined by the physical structure of your code, specifically where you write the variable declarations and function definitions:
const globalVar = "I'm global";
function outer() {
const outerVar = "I'm in outer";
function inner() {
const innerVar = "I'm in inner";
// inner can access: innerVar, outerVar, globalVar
console.log(innerVar); // "I'm in inner"
console.log(outerVar); // "I'm in outer"
console.log(globalVar); // "I'm global"
}
inner();
// outer can access: outerVar, globalVar
// outer CANNOT access innerVar
console.log(outerVar); // "I'm in outer"
console.log(globalVar); // "I'm global"
// console.log(innerVar); // ReferenceError
}
outer();The critical point: inner() can access outerVar because it is written inside outer(). This is true regardless of how or where inner is eventually called.
The Scope Chain
Every scope has a reference to its parent scope, forming a chain. Variable lookup follows this chain from inside out:
const a = 1;
function first() {
const b = 2;
function second() {
const c = 3;
function third() {
const d = 4;
// Lookup chain for `a`:
// 1. Check third's scope -> not found
// 2. Check second's scope -> not found
// 3. Check first's scope -> not found
// 4. Check global scope -> FOUND (a = 1)
console.log(a + b + c + d); // 10
}
third();
}
second();
}
first();Scope Chain Diagram
| Scope Level | Variables Available | Parent |
|---|---|---|
| Global | a, first | None |
first() | b, second | Global |
second() | c, third | first() |
third() | d | second() |
The lookup always goes outward (child to parent), never inward (parent to child) and never sideways (sibling to sibling).
Lexical Scope vs Dynamic Scope
JavaScript does NOT use dynamic scope (where variable lookup depends on the call stack). The difference matters when a function is called from a different context:
const value = "global";
function printValue() {
console.log(value); // Which value?
}
function wrapper() {
const value = "wrapper";
printValue(); // Calls printValue from wrapper's context
}
wrapper();
// OUTPUT: "global"
// NOT "wrapper" -- because printValue was DEFINED in global scope
// It looks up `value` in its lexical scope (global), not the call siteComparison Table
| Feature | Lexical Scope (JavaScript) | Dynamic Scope (Bash, some Lisps) |
|---|---|---|
| Determined by | Where function is written | Where function is called |
| Lookup direction | Outward through enclosing source code scopes | Upward through the call stack |
| Predictability | High, readable from source code | Low, depends on runtime call chain |
| Used by | JavaScript, Python, C, Java | Bash, Emacs Lisp, some Perl |
Block Scope vs Function Scope
let and const create block-scoped variables. var creates function-scoped (or global-scoped) variables:
function scopeDemo() {
if (true) {
var functionScoped = "I escape the block";
let blockScoped = "I stay in the block";
const alsoBlockScoped = "I also stay";
}
console.log(functionScoped); // "I escape the block"
// console.log(blockScoped); // ReferenceError
// console.log(alsoBlockScoped); // ReferenceError
}
scopeDemo();Block Scope in Loops
// var: One shared variable across all iterations
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log("var:", i), 100);
}
// Output: var: 3, var: 3, var: 3
// Because all callbacks close over the same `i`
// let: A NEW variable binding for each iteration
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log("let:", j), 100);
}
// Output: let: 0, let: 1, let: 2
// Because each iteration creates a new `j` in its own block scopeVariable Shadowing
A variable in an inner scope can "shadow" (hide) a variable with the same name in an outer scope:
const name = "Global Alice";
function greet() {
const name = "Local Bob"; // Shadows the global `name`
console.log(name); // "Local Bob"
}
greet();
console.log(name); // "Global Alice" (unchanged)Shadowing Through Multiple Levels
const x = 1;
function a() {
const x = 2; // Shadows global x
function b() {
const x = 3; // Shadows a's x
function c() {
console.log(x); // 3 (finds x in b's scope)
}
c();
}
b();
console.log(x); // 2 (a's own x)
}
a();
console.log(x); // 1 (global x)Accidental Shadowing Warning
const items = [1, 2, 3];
function processItems(items) { // Parameter shadows outer `items`
// Inside here, `items` refers to the parameter, not the outer array
return items.map((item) => item * 2);
}
// This is usually intentional, but can be confusing when the names match
// by accident. Use ESLint's `no-shadow` rule to catch unintended shadowing.Scope and Closures
Lexical scope is the mechanism that makes closures work. A closure is simply a function that retains its lexical scope even when executed outside of it:
function createCounter(start) {
let count = start;
return {
increment() {
return ++count;
},
getCount() {
return count;
}
};
}
const counter = createCounter(10);
// createCounter has finished, but count is still accessible
// because increment and getCount were DEFINED inside createCounter
// and lexical scope keeps count alive
console.log(counter.increment()); // 11
console.log(counter.increment()); // 12
console.log(counter.getCount()); // 12Module Scope
Each JavaScript module (ES6 import/export) has its own scope. Variables declared at the top level of a module are not global:
// utils.js
const SECRET = "abc123"; // Module-scoped, NOT global
export function getSecret() {
return SECRET; // Accessible via lexical scope
}
// main.js
import { getSecret } from "./utils.js";
console.log(getSecret()); // "abc123"
// console.log(SECRET); // ReferenceError -- not in main.js's scopeScope Hierarchy Summary
| Scope Type | Created By | Variable Keywords |
|---|---|---|
| Global | Top-level code, no module | var, let, const |
| Module | ES6 module file | let, const, var |
| Function | function declaration/expression | var, let, const, parameters |
| Block | {} with if, for, while, etc. | let, const only |
Scope Lookup Algorithm
When JavaScript encounters a variable name, the engine follows this exact process:
function resolveVariable(name, currentScope) {
// Step 1: Check the current scope
if (currentScope.has(name)) {
return currentScope.get(name);
}
// Step 2: Check the parent scope (recursively)
if (currentScope.parent !== null) {
return resolveVariable(name, currentScope.parent);
}
// Step 3: Reached global scope and still not found
throw new ReferenceError(`${name} is not defined`);
}Lookup Performance
The scope chain lookup is optimized by JavaScript engines. Modern engines like V8 resolve most variable lookups at compile time through static analysis. Deeply nested scopes do not cause measurable performance differences in practice.
Common Mistakes
Mistake 1: Assuming Call-Site Scope
const message = "Hello from global";
function logger() {
console.log(message); // Uses lexical scope, not call-site scope
}
function caller() {
const message = "Hello from caller";
logger(); // Prints "Hello from global" -- lexical scope wins
}
caller();Mistake 2: Forgetting Block Scope Rules
function example() {
// console.log(x); // ReferenceError: Cannot access 'x' before initialization
// This is the "temporal dead zone" for let/const
let x = 5;
console.log(x); // 5
if (true) {
let x = 10; // New block-scoped x, shadows the outer one
console.log(x); // 10
}
console.log(x); // 5 (outer x is unchanged)
}Rune AI
Key Insights
- Scope is determined at write time: Variable access depends on where the function is physically written in the source code, not where or how it is called
- The scope chain goes outward only: Lookup starts in the current scope and walks through parent scopes to global, never inward to child scopes or sideways to siblings
- Block scope vs function scope:
letandconstrespect block boundaries whilevarescapes blocks and is scoped to the enclosing function or global - Shadowing hides outer variables: An inner variable with the same name as an outer one takes priority, the outer variable is untouched but inaccessible in that inner scope
- Lexical scope enables closures: Because functions remember their write-time scope, they can access those variables even when called from an entirely different context
Frequently Asked Questions
What does "lexical" mean in lexical scope?
Is JavaScript lexically scoped or dynamically scoped?
How does the scope chain affect performance?
What is the temporal dead zone?
How do closures relate to lexical scope?
Conclusion
Lexical scope means variable access is determined by where code is written, not where it is called. The scope chain walks outward from the current scope through each enclosing scope until reaching the global scope. let and const create block scope while var creates function scope. Shadowing allows inner scopes to override outer names, and closures exist because functions carry their lexical scope wherever they go.
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.