Core Concepts10 min read · Updated 2025-06-01

JavaScript Scope Explained: Lexical Scope, Scope Chain, and How Variables Are Found

Scope isn't just "where variables are accessible" — it's the system that determines variable lookup at every single line of your code. Getting this right fixes bugs you've been working around for years.

💡 Practice these concepts interactively with AI feedback

Start Practicing →

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