Scope is one of those concepts that feels obvious until a bug proves it isn't. A variable that should be accessible isn't. A value that shouldn't have changed has. A function works perfectly in isolation but breaks when called from elsewhere.
Every one of these bugs has scope at its root. This guide builds the model that makes scope predictable, from the ground up.
The One-Sentence Definition That Actually Works
Scope is the ruleset that determines which variables are visible and accessible at any given point in your code.
That's it. Scope isn't a place. It's not a box. It's a set of rules that the JavaScript engine follows every time it needs to resolve a variable name.
Lexical Scope: Scope Is Determined By Where You Write Code
JavaScript uses lexical scope (also called static scope). A function's scope is determined at write time — by where the function appears in the source code — not at call time — by where the function is called from.
const language = 'JavaScript'
function outer() { const framework = 'React'
function inner() { const tool = 'Vite' // inner can see: tool, framework, language console.log(language, framework, tool) }
inner() // outer can see: framework, language — NOT tool }
outer() // global can see: language — NOT framework or tool
inner can see language and framework because those variables exist in the scopes surrounding where inner was written — not because of how inner is called. Move inner outside of outer, and it loses access to framework. The code structure determines access.
The Scope Chain: How Variables Are Looked Up
When JavaScript encounters a variable name, it doesn't search the entire program. It searches a chain of scopes, from innermost to outermost:
1. Current scope — is the variable declared here? 2. Next enclosing scope — if not, look here 3. Continue outward — up through all enclosing scopes 4. Global scope — last resort 5. ReferenceError — if still not found
const a = 1
function level1() { const b = 2
function level2() { const c = 3
function level3() { console.log(a) // found in global scope (step 4) console.log(b) // found in level1's scope (step 3) console.log(c) // found in level2's scope (step 2) console.log(d) // not found anywhere → ReferenceError }
level3() }
level2() }
level1()
This chain — level3 → level2 → level1 → global — is the scope chain. It's built from the nesting structure of the code and is fixed when the code is parsed.
The Four Types of Scope in JavaScript
1. Global Scope
Variables declared at the top level, outside all functions and blocks. Accessible everywhere. In a browser, global var declarations become properties of window. In Node.js, the global object is global.
var config = { env: 'production' } // on window.config in browsers
let version = '2.1.0' // NOT on window — block-scoped
const PI = 3.14159 // NOT on window — block-scoped
Global variables are shared across your entire program and every script on the page. Polluting the global scope is one of the most common causes of conflicts between scripts and libraries.
2. Function Scope
Variables declared inside a function are accessible only within that function and its nested functions. This is the scope var respects.
function calculateTotal(items) {
var subtotal = items.reduce((sum, item) => sum + item.price, 0)
var tax = subtotal * 0.08
return subtotal + tax
}
console.log(subtotal) // ReferenceError — subtotal is function-scoped
3. Block Scope
Introduced with ES6's let and const. Variables declared with these keywords are scoped to the nearest enclosing block — anything between {}.
{
let blockVar = 'only here'
const blockConst = 'also only here'
var funcVar = 'leaks out'
}
console.log(blockVar) // ReferenceError console.log(blockConst) // ReferenceError console.log(funcVar) // 'leaks out' — var ignores the block
This is why let in a for loop doesn't leak into the surrounding function — each iteration even gets its own separate binding, which is why the closure-in-loops bug disappears with let.
// var — one i, shared by all callbacks
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100) // 3, 3, 3
}
// let — new i per iteration, each callback closes over its own for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 100) // 0, 1, 2 }
4. Module Scope
ES6 modules (import/export) create module scope. Variables at the top level of a module are not global — they're scoped to that module. They must be explicitly exported to be accessible elsewhere.
// math.js
const PI = 3.14159 // module-scoped — not global
export function area(r) { return PI r r }
// app.js import { area } from './math.js' console.log(PI) // ReferenceError — PI is not exported, not accessible console.log(area(5)) // 78.54 — function is exported
Module scope is one of the most important improvements in modern JavaScript — it eliminates the global pollution problem that caused so many conflicts in script-tag era code.
Variable Shadowing: When Inner Wins
When an inner scope declares a variable with the same name as an outer scope, the inner variable shadows the outer one within its scope:
const status = 'active'
function processUser(user) { const status = user.banned ? 'banned' : 'ok' // shadows outer 'status'
console.log(status) // 'ok' or 'banned' — the LOCAL status
function audit() { console.log(status) // still the local one — 'ok' or 'banned' }
audit() }
processUser({ banned: false }) console.log(status) // 'active' — outer status untouched
Shadowing doesn't modify the outer variable — it creates a new variable that hides it within the inner scope. The outer variable still exists and is still accessible in scopes where the inner one isn't declared.
Shadowing is sometimes intentional and fine. It's a problem when you accidentally shadow a variable you meant to access:
function updateCount(count) {
// Intending to update the parameter, but accidentally redeclaring it
let count = count + 1 // SyntaxError with let — can't redeclare in same scope
// With var this would silently shadow — a common source of bugs
}
The Scope Chain Is One-Way
Child scopes can see parent scope variables. Parent scopes cannot see child scope variables. This is not negotiable — it's fundamental to how JavaScript's security and encapsulation model works.
function parent() {
const x = 10
function child() { const y = 20 console.log(x) // 10 — child sees parent }
child() console.log(y) // ReferenceError — parent cannot see child }
This one-way visibility is what makes functions reliable building blocks. A function's internal state is genuinely private to it and its children. Callers can't accidentally see or modify it.
Lexical Scope vs Dynamic Scope: Why This Matters
Some languages (older shells, Perl in some modes) use dynamic scope — variable lookup is based on the call stack, not the code structure. JavaScript does not. It uses lexical scope.
const x = 'global'
function readX() { return x // lexical: always reads global x, regardless of who called it }
function callWithLocalX() { const x = 'local' return readX() // in dynamic scope: would return 'local' // in JavaScript (lexical): returns 'global' }
callWithLocalX() // 'global'
In JavaScript, readX's scope chain was established when readX was defined — it sees x in global scope. The fact that it was called from inside callWithLocalX (which has its own x) is irrelevant. The call stack doesn't affect variable resolution. The code structure does.
This is why closures work reliably: the function carries its lexical scope with it, always, regardless of where it ends up being called.
The Practical Rules
Declare variables as close to their use as possible. Narrow scope = fewer side effects = easier debugging.
Prefer const. Block-scoped, can't be reassigned, immediately communicates intent.
Use let only when reassignment is necessary. Still block-scoped. Still TDZ-protected.
Never use var in new code. Function scope + initialization-to-undefined causes bugs that let/const physically cannot produce.
Use modules. Module scope eliminates global pollution. If you're using tags without type="module", every variable you declare at the top level is fighting for space on window.
Name variables precisely. Vague names like data, result, temp lead to accidental shadowing and require broader scope to carry context. Precise names allow tighter scope.
The Question That Ties It Together
Here's a scope chain question that covers everything:
var a = 1
let b = 2
function outer() { var a = 10 let b = 20
function inner() { console.log(a) // ? console.log(b) // ?
if (true) { var a = 100 let b = 200 console.log(a) // ? console.log(b) // ? }
console.log(a) // ? console.log(b) // ? }
inner() }
outer()
Output: undefined, 20, 100, 200, 100, 20
- First
ainsideinner:var a = 100is function-scoped toinner, so it's hoisted and initialized toundefinedbeforeinnerruns. Thevar ain theifblock doesn't create a new scope — it hoists toinner.
- First
binsideinner: nolet bininner's direct scope (theifblock'sbis block-scoped there). Scope chain goes up toouter'slet b = 20.
- Inside the block:
var ais now assigned100.let bin the block is200.
- After the block:
var ais still100(var ignores block boundaries).let bis back toouter's20(block scope ended).
This single question tests: var vs let scoping, hoisting, block scope, scope chain lookup, and shadowing. Every scope rule in one example.