open atlas
↑ Back to track
JavaScript Engine internals JSE · 05 · 01

Scopes, contexts, and the scope chain

Lexical scoping is identifier resolution along a chain of scopes. V8 describes scopes at compile time with ScopeInfo and materialises only the captured ones at runtime as heap Context objects linked by outer pointers — most loads resolve to a fixed (depth, slot).

JSE Middle ◷ 13 min
Level
FoundationsJuniorMiddleSenior
Already know this unit? Take a 1-minute quick check →

You read a variable named total inside a deeply nested callback. The engine has to find which total you mean — and it has to do it fast, on a hot path, millions of times. The naive answer is “search the surrounding functions at runtime.” The real answer is that V8 already worked out the exact location at compile time, and at runtime it is usually a single indexed load from a heap object. Until you write with or sloppy eval — and then the whole scheme collapses.

Lexical scope: a chain decided by where you wrote the code

JavaScript is lexically scoped: the meaning of an identifier is fixed by the textual nesting of functions and blocks, not by who called whom at runtime. When you reference total, the language defines a search: look in the current scope, then its enclosing scope, then the next, up to the global scope. The first declaration found wins. That ordered sequence of enclosing scopes is the scope chain.

The word “search” is doing a lot of damage in most people’s mental models. A linear runtime search of named variables — a dictionary lookup per access — would make every variable read in a closure painfully slow. V8 does not do that for the common case. It splits the work into two halves: a compile-time analysis that figures out where each variable lives, and a runtime structure that holds only the variables that actually need to survive.

ScopeInfo: the compile-time blueprint

Before you can avoid the slow path, you need to understand what V8 builds during compilation — and why that blueprint is the reason most variable reads are cheap. When V8’s parser and bytecode compiler (Ignition) process a function, they build a ScopeInfo object for each scope. ScopeInfo is pure compile-time metadata attached to the function’s SharedFunctionInfo. It records:

  • which variables the scope declares (the names),
  • which of them are context-allocated — must live on the heap because an inner function captures them — versus stack/register-allocated (purely local),
  • the slot index each context-allocated variable occupies,
  • the kind of scope (function, block, eval, module, with), and a link to the enclosing ScopeInfo.

Crucially, the compiler can usually resolve a variable reference to a fixed pair: (context depth, slot index). “The variable total is two scopes out, at slot 3.” That resolution is baked into the bytecode as a LdaContextSlot-style instruction. No name lookup happens at runtime — it is an array index into a known object at a known depth.

What resolution costs, by kind
Local in register/stack
0 loads (in a reg)
Context slot, depth 0
1 indexed load
Each extra scope of depth
+1 pointer hop
Global / script-context var
cell load (cached)
Truly dynamic (with / sloppy eval)
named runtime lookup
ScopeInfo lives on
SharedFunctionInfo

Context: the runtime scope chain

At runtime, the scopes that must outlive their stack frame are materialised as Context objects. A Context is a small heap object — internally a fixed-size array (a FixedArray flavour) — that holds the context-allocated variables of one scope, plus a pointer to its outer Context. That chain of outer pointers is the runtime scope chain.

Three things follow, and they govern everything in this unit:

  1. Only captured variables go in a Context. If the compiler can prove a variable is purely local — declared, used, and never referenced by any inner function — it stays in a CPU register or on the stack and is never heap-allocated. It vanishes when the frame returns. No closure can reach it because none was created over it.
  2. A Context is keyed by slot, not by name. Reading total two levels out is: dereference outer, dereference outer again, load slot 3. Fixed work, no hashing.
  3. The global scope is special. It is the global object (globalThis) plus a script context that holds top-level let/const. Top-level var and function declarations become properties on the global object; top-level let/const live in the script context as cells. Global reads therefore go through a cell or a property load, not a Context slot, and are cached by inline caches the same way object property reads are.

Together, rules 1 and 2 mean that the only variables paying the heap-allocation price are the ones an inner function actually observes — everything else is free. Rule 3 is why top-level module code has subtly different access costs from inner function code; it is worth knowing when you move hot reads to module scope expecting a speedup.

Why the fast path exists — and what destroys it

The whole optimisation rests on the compiler knowing the layout statically. Two language features break that guarantee and force V8 onto a slow, name-based path:

  • with (obj) injects an object whose properties are not known until runtime, so any identifier inside might resolve to a property of obj. The compiler cannot pre-compute a slot; it must emit a dynamic lookup that checks the object first. This poisons the scope for optimisation.
  • Sloppy-mode eval can declare new variables in the calling scope (e.g. an eval of var x = 1). Because the compiler cannot know what names eval will introduce, it must keep the scope dynamic — variables can no longer be resolved to fixed slots, and the enclosing function often cannot be optimised well.

Strict mode neutralises the eval case: eval in strict mode gets its own scope and cannot leak declarations outward, so the surrounding scope stays statically analysable. This is one of the concrete performance reasons strict mode exists.

function fast(rows) {
  let total = 0;                 // captured by the arrow -> context-allocated
  rows.forEach(r => total += r); // 'total' resolves to (depth 1, slot 0)
  return total;
}

function slow(rows, cfg) {
  with (cfg) {                   // every identifier below is now dynamic
    let total = 0;               // V8 cannot prove where 'limit' comes from
    rows.forEach(r => { if (total < limit) total += r; });
    return total;
  }
}
Quiz

A function declares a local `n`, increments it, returns it, and creates no inner functions. Where does V8 keep `n`?

Quiz

What does V8 typically resolve an outer-variable reference to, at compile time?

Order the steps

Order how V8 resolves a read of an outer variable on a hot path, from compile time to the runtime load.

  1. 1 Parser builds a scope tree and a ScopeInfo per scope
  2. 2 Compiler marks captured variables context-allocated and assigns each a slot index
  3. 3 The reference is encoded as a fixed (context depth, slot index) in the bytecode
  4. 4 At runtime, hop the outer pointer 'depth' times, then load that slot from the Context
Why this works

Why not put every variable in a Context for simplicity? Because Context objects are heap allocations the GC must track, and a heap load is far costlier than a register. V8’s whole strategy is to keep variables in registers/stack by default and pay for a Context only when capture forces it. The next two lessons are about exactly when that cost is incurred and what it retains.

Recall before you leave
  1. 01
    What is ScopeInfo and how does it differ from a Context?
  2. 02
    Which variables end up in a Context, and which stay in registers or on the stack?
  3. 03
    Why do `with` and sloppy-mode `eval` make scope resolution slow, and how does strict mode help?
Recap

JavaScript is lexically scoped: an identifier’s meaning is fixed by textual nesting, and resolution walks an ordered chain of enclosing scopes up to global. V8 splits this into compile time and runtime. At compile time the parser builds a ScopeInfo per scope — recording which variables exist, which are context-allocated because an inner function captures them, and the slot index of each — so most references compile to a fixed (context depth, slot index). At runtime, captured scopes are materialised as Context objects: small heap arrays of slots linked by outer pointers, which form the actual scope chain. Reading an outer variable is a few pointer hops plus one indexed load, not a name search. Variables the compiler proves are local stay in registers or on the stack and are freed with the frame. The global scope is the global object plus a script context for top-level let/const. The fast path depends on the compiler knowing the layout statically, which is exactly what with and sloppy-mode eval destroy by forcing dynamic, name-based lookup — strict mode preserves it for eval. Now when you see a profiler flagging a hot inner-function read as unexpectedly slow, the first question is whether with or sloppy eval is anywhere in that scope chain — because that is what collapses the fast (depth, slot) path into a runtime name search.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done
Connected lessons
appears again in184

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.