JavaScript Scope
Scope determines where in your code a variable, function, or object is accessible. Every time JavaScript creates a variable, it attaches that variable to a specific scope, and that scope decides which parts of your program can see and use it. Understanding scope is essential because it explains why some variables ‘leak’ outside blocks, why closures work, and why so many bugs come down to a variable being visible where you didn’t expect (or invisible where you did).
Overview: How Scope Works
JavaScript uses lexical scoping (also called static scoping), which means scope is determined by where variables and functions are written in the source code, not by how or when they are called. When the JavaScript engine parses your code, it builds a nested structure of lexical environments — think of them as containers that hold variable bindings. Every function, and every block delimited by { } (for let/const), creates a new environment that is nested inside the environment where it was physically written.
There are three main levels of scope in JavaScript:
- Global scope — variables declared outside any function or block. They are accessible everywhere in the program, including inside every function and block.
- Function scope — variables declared with
varinside a function are visible anywhere within that function, regardless of nested blocks likeiforfor. - Block scope — variables declared with
letorconstare visible only within the nearest enclosing pair of curly braces{ }, such as anifblock, aforloop, or a bare block.
The keyword you use to declare a variable determines which of these rules applies. var is function-scoped (and ignores block boundaries), while let and const are block-scoped. This difference is one of the biggest reasons let and const replaced var in modern JavaScript — block scoping matches how most programmers intuitively expect variables to behave.
Syntax
Scope itself has no special syntax — it emerges from where you place a declaration relative to curly braces and function boundaries.
// Global scope
const siteName = 'MySite';
function outerFunction() {
// Function scope (new environment for this function)
const role = 'outer';
if (true) {
// Block scope (new environment for this block)
let temp = 'block-only';
var leaked = 'function-wide';
}
}
const siteNamelives in the global environment and is reachable from anywhere.const roleis scoped toouterFunctionand cannot be used outside it.let tempis scoped only to theifblock — it disappears once the block ends.var leakedignores the block and is scoped to the whole ofouterFunction.
Examples
Example 1: Global, Function, and Nested Function Scope
const globalVar = 'I am global';
function outer() {
const outerVar = 'I am in outer';
function inner() {
const innerVar = 'I am in inner';
console.log(globalVar);
console.log(outerVar);
console.log(innerVar);
}
inner();
}
outer();
console.log(globalVar);
Output:
I am global
I am in outer
I am in inner
I am global
The inner function can read globalVar and outerVar because nested functions can always reach up into the scopes they are lexically written inside. But if you tried to log innerVar after calling outer(), you would get a ReferenceError — outer scopes can never reach down into inner ones.
Example 2: Block Scope vs. Function Scope
function testBlockScope() {
if (true) {
var varVariable = 'I leak out of the block';
let letVariable = 'I stay in the block';
const constVariable = 'I also stay in the block';
console.log(varVariable, letVariable, constVariable);
}
console.log(varVariable);
try {
console.log(letVariable);
} catch (error) {
console.log(error instanceof ReferenceError);
}
}
testBlockScope();
Output:
I leak out of the block I stay in the block I also stay in the block
I leak out of the block
true
Because varVariable was declared with var, it belongs to the whole function and is still readable after the if block ends. But letVariable only exists inside the block; trying to read it afterward throws a ReferenceError because it simply does not exist in that outer scope.
Example 3: Scope and Closures Inside Loops
console.log('--- Using var ---');
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log('var i:', i), 0);
}
console.log('--- Using let ---');
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log('let j:', j), 0);
}
Output:
--- Using var ---
--- Using let ---
var i: 3
var i: 3
var i: 3
let j: 0
let j: 1
let j: 2
This is the classic scope gotcha. Because var creates one single function-scoped (here, effectively global) i shared by every iteration, all three callbacks close over the same variable, which has finished looping and equals 3 by the time they run. With let, the for loop creates a fresh binding of j for every iteration, so each callback closes over its own independent value.
Under the Hood: The Scope Chain
When the engine needs to resolve a variable reference, it doesn’t search your whole program — it walks a linked structure called the scope chain. Here is the step-by-step lookup process:
- 1. The engine checks the current lexical environment (the innermost function or block) for the variable name.
- 2. If not found, it moves up to the environment that lexically encloses it — not the environment of whoever called the function, but the one where the function was physically defined in the code.
- 3. This continues outward, environment by environment, until it reaches the global scope.
- 4. If the variable still isn’t found, a
ReferenceErroris thrown (unless it’s an assignment in non-strict mode, which implicitly creates a global — see Common Mistakes).
Because the chain is built from where code is written, not from how it’s called, a function always ‘remembers’ the scope it was created in, even if you pass that function somewhere else and invoke it later. This is precisely the mechanism that powers closures: an inner function keeps a live reference to its outer function’s variables through the scope chain, even after the outer function has finished running.
It’s also worth knowing that let and const declarations are hoisted to the top of their block, but unlike var they are not initialized until the declaration line runs. The gap between the start of the block and the declaration is called the Temporal Dead Zone (TDZ) — accessing the variable during this window throws a ReferenceError, as shown in the second Common Mistakes example below.
Common Mistakes
Mistake 1: Creating Accidental Globals
Forgetting to declare a variable with let, const, or var before assigning to it does not throw an error in non-strict code — it silently creates a global variable, which can cause hard-to-track bugs across a large codebase.
function setTotal() {
total = 100;
}
setTotal();
console.log(total);
Output:
100
This ‘works’, but total is now a global variable that any other part of the program can accidentally overwrite. Always declare variables explicitly:
function setTotal() {
const total = 100;
return total;
}
console.log(setTotal());
Output:
100
Using 'use strict' at the top of a file or module turns the original mistake into an immediate, catchable ReferenceError instead of a silent global — another good reason to use it (ES modules and classes are strict by default).
Mistake 2: Reading a Variable Before Its Declaration (the TDZ)
A common source of confusion is shadowing an outer variable with a let or const of the same name further down in the same scope. Even though the outer variable exists, JavaScript refuses to ‘fall back’ to it — it knows a local status is coming and locks access until that line runs.
const status = 'ready';
function checkStatus() {
try {
console.log(status);
} catch (error) {
console.log('Error:', error.message);
}
const status = 'pending';
console.log(status);
}
checkStatus();
Output:
Error: Cannot access 'status' before initialization
pending
The fix is simply to avoid shadowing names you still need to reference, or to declare the local variable before using it in that scope.
Best Practices
- Prefer
constby default, andletonly for variables you know will be reassigned; this makes scope and intent clear at a glance. - Avoid
varin modern code — its function-scoping and hoisting behavior causes bugs that block-scopedlet/constsimply prevent. - Keep variables as narrowly scoped as possible — declare them inside the block or function where they’re actually used, not at the top of a large function ‘just in case’.
- Minimize global variables; wrap script-level code in a function, module, or an Immediately Invoked Function Expression (IIFE) to avoid polluting the global object.
- Use
'use strict'(automatic in ES modules and classes) so accidental undeclared assignments throw errors instead of silently creating globals. - Watch for variable shadowing — reusing the same name in a nested scope is legal but easy to misread; give inner variables distinct names when possible.
- Remember that closures keep their whole enclosing scope alive in memory, not just the variables they use — avoid capturing large objects in long-lived closures unnecessarily.
Practice Exercises
- Exercise 1: Write a function
counter()that declares alet count = 0inside it, and returns an inner function that increments and returnscounteach time it’s called. Call the returned function three times and predict each result before running it. - Exercise 2: Take a loop that uses
var ito schedule threesetTimeoutcallbacks loggingi, and rewrite it usingletso each callback logs its own iteration’s value instead of the final one. Explain in one sentence why the behavior changes. - Exercise 3: Predict the output of a script that declares
const x = 'outer'globally, then defines a function that logsx, declares its ownlet x = 'inner'below that log, and calls the function. What error occurs, and why?
Summary
- Scope defines where a variable is visible, and JavaScript uses lexical (write-time) scoping to determine it.
- Global scope is visible everywhere; function scope (
var) is visible throughout a function; block scope (let/const) is limited to the nearest{ }. - The scope chain lets inner functions read variables from every enclosing scope, which is the mechanism behind closures.
letandconstare hoisted but stay in the Temporal Dead Zone until their declaration line executes, throwing aReferenceErrorif accessed early.- Forgetting to declare a variable creates an accidental global in non-strict mode — use
'use strict'and always declare withconst/let. - Prefer
const/let, keep scope as narrow as possible, and avoid unnecessary global state.
