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 var inside a function are visible anywhere within that function, regardless of nested blocks like if or for.
  • Block scope — variables declared with let or const are visible only within the nearest enclosing pair of curly braces { }, such as an if block, a for loop, 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 siteName lives in the global environment and is reachable from anywhere.
  • const role is scoped to outerFunction and cannot be used outside it.
  • let temp is scoped only to the if block — it disappears once the block ends.
  • var leaked ignores the block and is scoped to the whole of outerFunction.

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 ReferenceError is 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 const by default, and let only for variables you know will be reassigned; this makes scope and intent clear at a glance.
  • Avoid var in modern code — its function-scoping and hoisting behavior causes bugs that block-scoped let/const simply 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 a let count = 0 inside it, and returns an inner function that increments and returns count each 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 i to schedule three setTimeout callbacks logging i, and rewrite it using let so 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 logs x, declares its own let 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.
  • let and const are hoisted but stay in the Temporal Dead Zone until their declaration line executes, throwing a ReferenceError if accessed early.
  • Forgetting to declare a variable creates an accidental global in non-strict mode — use 'use strict' and always declare with const/let.
  • Prefer const/let, keep scope as narrow as possible, and avoid unnecessary global state.