Understanding the Scope Chain in JavaScript

When two variables have the same name, which one does a function read? Follow the enclosing scopes in the source, not the path of function calls. JavaScript stops at the first matching binding. It does not keep searching for a more convenient value.

In this lesson, you'll trace that search through functions and blocks, explain why a caller's locals do not replace a function's outer bindings and distinguish a missing name from an uninitialized one.

First pass: trace the lookup and caller examples and work through shadowing. Then try the first four exercises. Use Scope Chain Lookup Mechanics and Variable Shadowing in practice to check your explanation. You can stop when you can name the selected binding and explain why lookup stops there. The returned-function example and execution-context sections are optional bridges, not prerequisites for the first attempt.

Each JavaScript fence is a separate Node 24 CommonJS file. Save one as example.cjs and run node example.cjs. Top-level declarations in these files are module-local, not properties on a browser's window. Examples that intentionally throw say so.

Scope Chain Lookup

Start Where the Read Happens

Before running this file, find a declaration for each name read in innerFunction. The final read intentionally fails.

var globalVar = 'Global'

function outerFunction() {
  var outerVar = 'Outer'

  function innerFunction() {
    var innerVar = 'Inner'
    console.log(innerVar)
    console.log(outerVar)
    console.log(globalVar)
    console.log(nonExistent)
  }

  innerFunction()
}

outerFunction()

It prints Inner, Outer and Global on separate lines. The final read throws ReferenceError: nonExistent is not defined. It does not log undefined.

ReadSearchResult
innerVarCurrent function environmentInner
outerVarCurrent environment -> this call to outerFunctionOuter
globalVarCurrent environment -> outer call -> this file's module environmentGlobal
nonExistentNo matching binding in any enclosing environmentReferenceError

The name globalVar is inherited from the example, not a claim about its host scope. In this CommonJS file it belongs to the module. Host-provided globals are available farther outward.

This is a logical lookup diagram, not a required memory layout:

innerFunction call
  innerVar = "Inner"
        |
        v
outerFunction call
  outerVar = "Outer"
        |
        v
module
  globalVar = "Global"
        |
        v
host global environment

At every step: does this environment contain the name?
Yes -> use that binding and stop searching.
No  -> try the outer environment.

If the read is inside a nested block, start with that block's environment. Starting every search at the function boundary would skip block-scoped bindings.

The Caller Is Not the Lexical Parent

Predict this before running it:

let name = 'Global'

function printName() {
  console.log(name)
}

function shadowName() {
  let name = 'Local'
  printName()
}

shadowName()

It prints Global. printName was defined at the top level of this file. Its outer lookup reaches the module's name, not the local name of the function calling it.

The call stack includes shadowName, but the scope chain for the read does not pass through shadowName's locals. Calling the same function from somewhere else does not rewrite its lexical parent.

Source nesting determines which scopes enclose the function. When a function object is created, it retains access to the relevant outer environment. Calling it creates a new environment for that call's parameters and locals. These are related steps, not a choice between “everything happens at compilation” and “everything depends on the caller.”

Identifiers Are Not Properties

In account.balance, scope lookup first resolves the identifier account. Reading balance then uses that object's property rules. It does not search surrounding scopes for a variable named balance. You do not need prototype-chain details to trace the identifiers in this lesson.

Shadowing and First-Binding Lookup

Function Scope Shadowing

An inner declaration with the same name hides an outer declaration from reads in that inner scope.

var value = 'Global'

function shadowingExample() {
  var value = 'Local'
  console.log(value)
}

shadowingExample()
console.log(value)

This prints Local then Global. The function's assignment did not update the module's value.

Block Scope Shadowing

let number = 10

if (true) {
  let number = 20
  console.log(number)
}

console.log(number)

This prints 20 then 10. The same first-binding rule works for blocks.

Now move the inner read earlier. Does JavaScript fall back to the outer 10?

let number = 10

if (true) {
  console.log(number)
  let number = 20
}

No. The inner binding already shadows the outer one, but it is uninitialized. This file throws a ReferenceError with no output. “Not found here” means keep searching. “Found but uninitialized” means the read fails.

Shadowing with Parameters

Parameters are bindings too:

var name = 'Global'

function greet(name) {
  console.log('Hello, ' + name)
}

greet('Alice')

The output is Hello, Alice. The argument initializes the call's parameter binding, which is nearer than the module's name.

A Nested Block Is Not a New var Scope

These are separate files. In the first, the nested function gives var its own scope:

let x = 1
function showInner() {
  var x = 2
  console.log(x)
}
showInner()
console.log(x)

It prints 2 then 1. The ordinary block in this second file does not do the same:

let x = 1
{
  var x = 2
}

The whole file has a SyntaxError. That var x belongs to the surrounding module wrapper's variable scope and conflicts with let x. No assignment runs.

Best Practices for Managing Scope

  • Prefer local state with an obvious owner. A global name gives more code an opportunity to change it.
  • Use let or const when a value should stay within a block. var does not acquire a new scope just because it appears between braces.
  • Shadow names deliberately. A different name is often clearer when both values matter to the same piece of work.
  • Trace declarations before values. An uninitialized inner binding still stops lookup.
  • Do not add caller locals to a scope diagram. Draw the call stack separately when you need to explain execution order.
  • Treat an assignment without a declaration as a read-like search for an existing binding, followed by a write. It does not declare a function-local variable. If no binding exists, strict code and modules throw a ReferenceError. Non-strict code can create a global-object property instead.

Configurable lint rules such as no-shadow and no-undef can help catch mistakes. Their behavior depends on the project's enabled rules, not on every linter having the same defaults.

Exercises

Exercise 1: Scope Chain Lookup

Predict both logs. For the first, name every environment searched before the match.

var x = 10

function outer() {
  var x = 20

  function inner() {
    console.log(x)
  }

  inner()
}

outer()
console.log(x)

Answer

The output is 20 then 10. Inside inner, no local x exists. The search reaches this call to outer and stops at its x. The final log runs in the module and reads the module's x.

Exercise 2: Variable Shadowing

Return to the complete printName/shadowName example above. Why does it print Global even though shadowName is currently running? What would change if the printName declaration were moved inside shadowName, after its local declaration?

Answer

The original function's lexical parent is the module. Moving the declaration inside shadowName creates a different program: the new function would close over that call's environment and print Local. Merely moving the call does not have that effect.

Exercise 3: Block Scope

Which reads succeed? Which errors are caught?

if (true) {
  var a = 5
  let b = 10
  const c = 15
}

console.log(a)
try {
  console.log(b)
} catch (err) {
  console.log(err.message)
}
try {
  console.log(c)
} catch (err) {
  console.log(err.message)
}

Answer

In Node 24:

5
b is not defined
c is not defined

a belongs to the module wrapper's variable scope, not the if block. b and c are absent from the outer lookup. These are not TDZ errors. Catching the first error allows execution to reach the second failing read. Without the catches, the first error would stop the trace.

Exercise 4: Hoisting and Temporal Dead Zone

Predict the changed-input trace. The caller has an x, the module has an x and the current block has an x. Which one matters?

let x = 'module'

function read() {
  {
    console.log(x)
    let x = 'block'
  }
}

function caller() {
  let x = 'caller'
  read()
}

caller()

Answer

There is no output. The current block's uninitialized x causes a ReferenceError. Lookup neither falls through to the module's initialized value nor substitutes the caller's local.

For a second attempt, move the block's declaration above its log. That variation prints block. Removing the block declaration entirely instead makes the read reach module.

You can stop the core route here if you can explain those three outcomes. The stored Lexical Capture Output Prediction question is a simpler returned-function bridge, not this changed-input exercise.

Exercise 5: Function Scope and Closure

Optional bridge: return function () { ... } returns a function value without calling it. Assigning that result gives you a function you can call later. The later counter1() and counter2() calls run the returned functions.

function makeCounter() {
  let count = 0
  return function () {
    count += 1
    console.log(count)
  }
}

const counter1 = makeCounter()
counter1()
counter1()

const counter2 = makeCounter()
counter2()

Answer

It prints 1, 2 and 1 on separate lines. Each factory call creates a fresh count. Repeated calls to counter1 read and update its one binding. counter2 has a different outer environment from its separate factory call.

The outer calls have returned, but the returned functions still have the required lexical access. Continue to Closures for shared methods, independent state and saved callbacks.

Understanding Scope

This is a reference bridge if the scope names still feel unfamiliar:

ScopeUseful questionExample here
BlockWas this lexical declaration made in a nearer block?Inner number
FunctionWhich call supplied this parameter or local?greet's name
ModuleWas this declaration made at the file's top level?The CommonJS globalVar example
GlobalIs this a host/global binding outside the module?Host-provided console

An inner scope can use accessible outer bindings. Outer code cannot search inward into a function's locals. In browser classic scripts, top-level declarations use the global environment instead of the module environment shown here. Their global-object behavior is covered in the declaration lesson.

Introduction to Execution Context

Optional terminology: an execution context tracks information needed to run a piece of code. An ordinary function invocation creates a new context with its call's parameters and local state. Scripts, modules and evaluated code also have execution-context rules.

That is different from a function's retained lexical parent. A function does not execute by reusing an old caller's context just because it reads an outer binding. The caller matters to the order of work and the return destination, not to choosing the lexical parent.

The call stack tracks active calls. The scope chain explains name resolution. For a fuller call-stack trace, use Context and Call Stack. No eval or this machinery is needed for the lookup examples above.

Variable Environments and Lexical Environments

Optional specification vocabulary: an environment record models bindings and has an outer-environment link. A function object retains its defining environment. A call creates a new function environment with that retained environment as its outer link. Entering a block can add a nearer lexical environment.

An execution context's LexicalEnvironment identifies where lexical lookup currently starts. Its VariableEnvironment is used for variable-scoped declarations such as var. They can initially refer to the same environment and differ while a nested block is active. Neither term means that all declarations must live in one physical object.

Use this vocabulary to label the earlier traces, not as a second lookup algorithm. Find the first binding and apply its initialization and access rules. The abstract model explains observable behavior without prescribing an engine's storage layout or lookup speed.

Practice Problems

Completion marks record your own progress, not an automatically checked result. The task type does not determine whether it is optional.

TheoryEasy

Lexical Capture Output Prediction

TheoryEasy

Variable Shadowing

TheoryMedium

Scope Chain Lookup Mechanics

TheoryMedium

Block Scope vs Function Scope

TheoryMedium

Lexical vs Dynamic Scoping

Lesson completed?

Found a bug, typo, or have feedback?

Let me know