Home / Articles / How JavaScript's Scope Chain Actually Resolves Variables

This article is published in English.

How JavaScript's Scope Chain Actually Resolves Variables

Explains the lexical scope chain mechanism behind variable lookup, why var causes loop bugs, and how closures naturally follow from it.

1323 words

Variable Lookup Isn't About Curly Braces

The typical way people first learn about scope is to say "scope is whatever's inside the braces." That description gets you through a quiz, but it won't help you predict what a piece of code actually does. The real mechanism is more mechanical and more predictable than that shorthand suggests, and once you understand it, closures stop looking like magic.

Scope Is Fixed by Where You Write Code, Not by How It Gets Executed

JavaScript resolves variables lexically. That means a variable's scope is locked in based on its physical location in the source file at the time you author it, not by whichever function happens to call whichever other function while the program runs.

const value = "outer";

function readValue() {
  console.log(value);
}

function runWithDifferentValue() {
  const value = "inner";
  readValue(); // still logs "outer", not "inner"
}

runWithDifferentValue();

readValue has no interest in who invokes it or what variables live in the caller's environment. All it cares about is where it was physically defined, sitting right beside const value = "outer". That is the only value it will ever be able to see, no matter where in your program it eventually gets called from. This is usually the point where developers coming from languages with dynamic scoping (or really, developers who've just never had to think about it, since lexical scoping is the default almost everywhere) get confused. Scope is baked into the shape of the code the instant it's written; it doesn't shift depending on the call stack at runtime.

The Real Lookup Mechanism: Walking the Scope Chain

When the engine needs to resolve a variable reference, it doesn't search your entire codebase. It starts exactly at the spot where the variable is used and moves outward, one enclosing scope at a time, stopping as soon as it finds a match:

const a = "global";

function outer() {
  const b = "outer";

  function inner() {
    const c = "inner";
    console.log(a, b, c); // "global outer inner"
  }

  inner();
}

outer();

inner checks for c first and finds it right there, no travel required. Then it checks for b. It's not defined locally, so the search steps out to outer's scope, where it turns up. Then it checks for a, which isn't in inner or outer, so the search keeps stepping outward until it reaches global scope, where it finally locates it. That sequence, inner scope, then the scope wrapping it, then the scope wrapping that one, all the way up to global, is the whole lookup algorithm. There's nothing more sophisticated going on than "look here, then look one level further out, repeat until found."

This same mechanism explains variable shadowing without needing a separate rule. If inner declared its own const b, the search would stop the moment it found that local b and would never even reach the b defined in outer. Nothing gets overwritten in this scenario; it's simply that the closer match is found first, so the walk never has a reason to continue.

Why var Behaves Differently, and Why That Causes a Well-Known Bug

let and const attach themselves to the nearest enclosing block, meaning any pair of curly braces, whether that's an if statement or a loop body. var doesn't follow that rule at all. Instead, var attaches to the nearest enclosing function, ignoring block boundaries entirely.

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// logs: 3, 3, 3

There is exactly one i in this snippet, scoped to the function, and every iteration of the loop shares that same single variable. By the time any of the setTimeout callbacks actually fires, the loop has already finished running, and i has already climbed to 3. All three callbacks are closing over the identical variable rather than each getting its own copy, so they all report the final value it ended up holding.

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// logs: 0, 1, 2

Because let is block-scoped, and because the language specifically creates a fresh binding of i for every pass through the loop, each callback ends up closing over its own distinct i, frozen at whatever value it held during that particular iteration. The code looks almost identical to the previous example, but the underlying scoping rule is different, and it produces a far more predictable outcome.

Closures Aren't a Separate Feature, Just a Consequence of the Scope Chain

Once you understand how the scope chain works, closures need almost no extra explanation. A closure isn't some additional mechanism layered on top of scope. It's simply what naturally happens whenever you define a function inside another function, and that inner function then gets used somewhere after the outer scope would normally have been discarded.

function debounce(fn, delayMs) {
  let timeoutId;
  return function (...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn(...args), delayMs);
  };
}

const debouncedSearch = debounce((query) => runSearch(query), 300);

debounce executes exactly once. If you're used to languages without closures, your instinct might be that timeoutId should disappear as soon as debounce finishes running. It doesn't disappear, because the function debounce returns was defined inside it, meaning that returned function's scope chain permanently includes debounce's own scope, timeoutId included. Every subsequent call to debouncedSearch, however much later it happens, is walking that same scope chain back to that exact same timeoutId. That's precisely why debounce logic works: there needs to be a single, persistent variable tracking the pending timeout across every invocation, and the closure is what guarantees that.

The Same Feature That Powers Closures Can Also Leak Memory

What makes closures useful is the exact same property that produces a specific, recurring problem: a closure retains its whole surrounding scope, not merely the variables it actually references, and it holds a live reference to the variable itself rather than a frozen copy of its value at the moment the closure was created.

function createHandlers() {
  let clickCount = 0;
  const massiveDataset = loadHugeArray(); // large, no longer needed after setup

  return {
    onClick: () => {
      clickCount++; // only this variable is actually used
      console.log(clickCount);
    },
  };
}

onClick's closure captures the entire scope belonging to createHandlers, including massiveDataset, even though onClick never actually reads it. For as long as onClick stays alive, everything else it closed over stays alive too, which is a genuine, if usually small, memory concern whenever a long-lived closure ends up holding references to large or unneeded data. And because the closure holds the real variable rather than a copied value, it always sees the current, up-to-date state. That's the same underlying rule that made the debounce example work correctly and that made the var loop print 3, 3, 3, a single mechanism that shows up as a useful feature in one context and as a bug in another.

Understanding the Mechanism Beats Memorizing the Definition

The textbook line "a closure is a function paired with its lexical environment" is technically correct, but it rarely sinks in until you've manually traced the scope chain a few times and watched exactly where the lookup stops. Once tracing that chain becomes second nature, closures stop requiring a special explanation at all. They're just an ordinary consequence of how scope always worked, applied to a function that happens to outlive the environment it was created in.