JavaScript Lexical Scope: How the Engine Resolves Every Variable

Link copied
JavaScript Lexical Scope: How the Engine Resolves Every Variable

JavaScript Lexical Scope: How the Engine Resolves Every Variable

JS Tutorial Module 2: Functions Lesson 2.4

When you write console.log(name), what does JavaScript do to find name? It does not check what function called the current one. It does not check the most recently assigned variable in the call stack. It does something called lexical scoping — and once you understand that, closures, the this keyword, and module isolation all become straightforward.

This is Part 3 of the JavaScript Fundamentals series. Knowing the execution context and hoisting is a prerequisite — we build on both.

Lexical = "based on where it is written" #

In computer science, two scoping strategies have dueled for decades:

  • Lexical (static) scope — a variable's accessibility is determined by where the code is written. The compiler can figure out every variable lookup just by reading the source.
  • Dynamic scope — a variable's accessibility is determined by where the code is called from. The runtime has to trace the call stack.

Lisp 1.5 used dynamic scope by mistake and the language community spent years cleaning it up. JavaScript uses lexical scope — the modern, sane choice.

Lexical scope means: every time you write a function, its scope is frozen by where the function appears in the source code. Calling it from anywhere else does not change what it can see.

Three kinds of scope in modern JS #

JavaScript has three scope types:

  1. Global scope — the outermost scope. Variables declared at the top level of a script tag (without modules) live here. Modules each have their own scope, so the truly global scope is only globalThis (or window in browsers).
  2. Function scope — every function declaration / expression / arrow function creates one. var and function declarations are visible throughout this scope.
  3. Block scope — every { ... } (including if, for, while, the body of a function, a class, a try, a switch case, even a bare {}) creates one. let and const declarations are visible only inside this block.

These scopes nest. A block inside a function inside the global scope is three layers deep.

The scope chain #

When the engine encounters a variable, it does this:

  1. Check the current (innermost) scope's bindings.
  2. If not found, check the outer (enclosing) scope.
  3. Repeat outward until it finds the binding — or reaches the global scope and throws ReferenceError.

This chain of references-to-outer-scopes is the scope chain.

const tax = 0.08;

function priceFor(country) {
  const rate = country === 'IN' ? 0.18 : tax;   // ⬅ lookup: rate (local), tax (outer)
  return function (amount) {
    return amount * (1 + rate);                  // ⬅ lookup: amount (local), rate (outer)
  };
}

const quote = priceFor('US');
quote(100); // 108

When the engine evaluates amount * (1 + rate):

  1. amount — found in the inner function's scope. Done.
  2. rate — not in the inner scope. Look outward into priceFor's scope. Found. Done.

Neither lookup ever consults the call stack. The chain is built purely from how the code was written.

Why "where it was called from" does NOT matter #

This is the example that proves lexical scope is what it claims:

let message = 'outer';

function greet() {
  return message;
}

function shadow() {
  let message = 'inner';
  return greet();   // ⬅ called from inside shadow, BUT...
}

shadow(); // 'outer'

If JavaScript used dynamic scope, greet() would see shadow's message ('inner') because shadow is what called it. But JavaScript uses lexical scope: when greet was written, its outer scope was the module/global scope. That is what it sees, regardless of who calls it.

This property is what makes closures (Part 4) possible.

Block scope in detail #

function process(items) {
  for (let i = 0; i < items.length; i++) {
    const item = items[i];
    if (item.active) {
      const reason = item.reason;
      console.log(reason);
    }
    // console.log(reason); // ReferenceError — reason is gone
  }
  // console.log(item);   // ReferenceError — item is gone
  // console.log(i);      // ReferenceError — i is gone
}

Every { opens a new block scope. Every } closes it and discards the local bindings. The narrower the scope, the less code you have to reason about — block scope is one of the strongest tools we have for keeping mental load low.

var ignores block scope entirely:

function old() {
  if (true) { var leaky = 42; }
  console.log(leaky); // 42 — leaks out
}

Which is one more reason to never use var in 2026.

Shadowing #

A narrower scope can declare a variable with the same name as one further out. This is called shadowing:

const user = 'admin';

function logout() {
  const user = 'guest';    // shadows outer 'user'
  return user;             // 'guest'
}

logout();       // 'guest'
console.log(user); // 'admin' — outer was never touched

Shadowing is sometimes necessary, often a smell. It is fine for small, local variables; it is a footgun for module-level names because future readers will not realize there is an outer one.

Try not to shadow built-ins:

function badIdea() {
  const Math = { random: () => 0.5 }; // technically legal — please don't
  return Math.random();
}

Modules each get their own scope #

In modern JavaScript (ESM), every file is its own module with its own top-level scope. A variable declared at the top of user.js is not automatically visible in order.js.

// user.js
const DEFAULT_ROLE = 'member';
export function role() { return DEFAULT_ROLE; }

// order.js
import { role } from './user.js';
console.log(DEFAULT_ROLE); // ReferenceError — module scope!
console.log(role());       // 'member' ✓

This is a deliberate, huge improvement over the old <script> model where everything leaked into the global namespace. Modules are how we keep large codebases sane.

For a refresher on the module systems themselves, see our post on CommonJS, AMD, and ES6 modules.

The scope chain at execution #

Recall from Part 1 that every execution context carries a lexical environment — a reference to its outer scope. The scope chain is just the linked list you get by following that outer reference upward.

┌────────────────────────────────────────┐
│ inner function context                 │
│   bindings: { x: 1 }                   │
│   outer ↑                              │
├────────────────────────────────────────┤
│ outer function context                 │
│   bindings: { y: 2 }                   │
│   outer ↑                              │
├────────────────────────────────────────┤
│ module/global scope                    │
│   bindings: { z: 3 }                   │
│   outer: null                          │
└────────────────────────────────────────┘

When the inner function evaluates x + y + z, the engine walks this chain:

  • x — found in inner.
  • y — not in inner; follow outer ↑ — found in outer function.
  • z — not in outer; follow outer ↑ — found in module.

The whole chain is fixed when the inner function was created, not when it is called.

Why "created" not "called" matters for performance #

Because the chain is determined lexically, modern engines (V8, JSC, SpiderMonkey) can pre-compute most variable lookups during compilation. They know that x is in slot 0 of the local environment, y is in slot 1 of the parent environment, and so on. At runtime there is no string-keyed lookup; just array indexing. This is why JavaScript runs so fast despite looking dynamic.

The one thing that defeats this optimization is with and eval — they introduce scope at runtime, so the engine has to fall back to slow string-keyed lookups. Modern style guides forbid both.

Function declarations vs function expressions and scope #

// Function DECLARATION — name in outer scope, callable everywhere in outer
function declared() { return 'd'; }
console.log(declared());

// Function EXPRESSION — name (if any) only inside the function body
const expr = function namedExpr() {
  return namedExpr; // namedExpr is visible HERE only, not outside
};
console.log(expr());
console.log(typeof namedExpr); // 'undefined' — not in outer scope

Useful for recursion in expressions without leaking the helper name outward.

Common gotchas #

Gotcha 1: Closing over a loop variable with var.

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

All three callbacks share the same i (one function-scoped binding). Replace var with let to give each iteration its own binding.

Gotcha 2: Accidental global from missing let/const.

function tick() {
  counter = (counter || 0) + 1; // creates an implicit global in non-strict mode
}

In strict mode (default in modules and classes), this throws. Otherwise it silently leaks counter onto globalThis. Always use const/let.

Gotcha 3: Hoisted function declarations clash with imports.

import { greet } from './x.js';
function greet() {} // SyntaxError: Identifier 'greet' has already been declared

Module-scope imports occupy a slot you cannot redeclare.

Gotcha 4: The globalThis rabbit hole. In a browser, globalThis === window. In Node, globalThis === global. In a service worker, it is self. Use globalThis if you need cross-environment global access — it is the only spec-blessed name.

Recap #

  • Lexical scope = variable accessibility is fixed by where the code is written, not called.
  • JavaScript has three scope types: global, function, block ({ }).
  • The scope chain is the linked list of outer environments. Lookups walk it inside-out until they find the binding or reach global.
  • The chain is fixed at function creation, which makes lexical scope the foundation for closures.
  • Modules have their own scope — variables do not leak across files.
  • Engines pre-compute most lookups, which is why with and eval are forbidden in modern code.

Next up: Closures — what happens when a function survives the call that created it, and how that property enables module patterns, partial application, memoization, and almost every higher-order JavaScript pattern.

Up next in JavaScript

More from this topic

View all JavaScript articles →

Enjoyed this article?

Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email address will not be published. Required fields are marked *