cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

Scope analysis & variable allocation

Learn how JavaScript engines bind names before execution, choose stack or Context storage, and why closures, eval, and with change allocation.

By the end, you can
  • 01
    Read a scope treeExplain how parsers bind identifier references to declarations before code runs.
  • 02
    Predict allocationClassify locals as stack/register, context-allocated, or dynamic.
  • 03
    Connect to engine evidenceRecognize V8 Context, ScopeInfo, context-slot bytecodes, and DevTools scope clues.

Where variables live is decided early

Scope analysis is the compile-time pass that builds a scope tree, binds identifier references to declarations, and records enough information for the engine to choose where each variable will live before ordinary execution starts.

The language-level lessons already covered Scope, Closures, Execution contexts, Hoisting, Stack & heap, eval, and Strict mode. This lesson is the engine-level view: how those rules become a storage plan.

Working definition

Scope analysis answers two questions before the function runs: which declaration does each name mean, and can that binding stay in the frame/registers or must it live in a heap Context?

Real-life analogyA notebook on your desk and a shared class notebook

Keep a temporary note in the notebook on your desk while you work. If a classmate must read it after you leave, put it in the shared class notebook. A closure is that classmate; a V8 Context is the shared notebook.

In real life: Your desk notebook after class
In JavaScript: A stack/register local freed when the call returns
In real life: A class notebook anyone can reopen
In JavaScript: A heap Context slot a closure can use later
In real life: A changing class list
In JavaScript: Dynamic lookup from sloppy eval or with

Where the analogy stops: Real engines do not use notebooks. V8 can use registers, stack slots, heap Context objects, and optimizations that preserve behavior.

Resolving variables at parse time

LIVE ANALYZER

Lexical scoping means most variable references can be resolved from the written shape of the program. A parser or scope analyzer builds scopes for functions, blocks, catch clauses, modules, and classes. Then each identifier reference is bound to the nearest matching declaration, or marked global/unresolved.

A simple statically resolved functionPop out in the code editor (opens in a new tab)JavaScript
function priceLabel(price) {  const currency = "₹";  const rounded = Math.round(price);  return currency + rounded;} console.log(priceLabel(19.4));

The references to price, currency, and rounded do not need runtime guessing. The analyzer can point each one at a declaration before the first call.

Live static scope analyzer
Color-coded identifiers
function makeCounter() {
  let count = 0;
  const label = "jobs";
  return function next() {
    count += 1;
    return label + ":" + count;
  };
}
stack/registerContextdynamicglobal/top-level
Scope tree
  • script script
  • function makeCounter function
  • function next function
Declared variables
  • makeCounter global/top-leveltop-level binding; engines handle top-level script/module storage specially
  • count Contextreferenced from an inner function, so it needs a heap Context slot
  • label Contextreferenced from an inner function, so it needs a heap Context slot
  • next stack/registernot captured by an inner function; it can stay in the function frame/registers
Try it yourself

The analyzer parses your code with acorn, builds scopes, resolves identifiers, and classifies declared variables without executing the program.

This is a teaching analyzer for a JavaScript subset, not V8 itself. It never evaluates learner code.

The playground is intentionally smaller than V8, but it is real code: it uses acorn and acorn-walk to parse learner code, build a scope tree, resolve identifiers, and classify variables.

Stack/register slots vs Context slots

SORTER

V8's public docs describe a compile-time Scope that tracks declarations, references, and the allocation decision. A variable that never has to outlive the call can use fast frame or register-style storage. A variable captured by an inner function needs heap storage in a Context object.

The V8 blog post Blazingly fast parsing, part 2: lazy parsing calls this the variable allocation problem: the engine must know whether a variable referenced by an inner function is stack-allocated or context-allocated. V8's runtime scope docs name the related objects: Scope, ScopeInfo, and Context.

Variable allocation decisions
Engine decisionWhen it appliesWhat the learner should picture
Stack/registerA variable is local to one function activation and not captured by an inner functionFast storage in the current frame; it disappears when the call returns
Context-allocatedAn inner function can use the variable after the outer call returnsA heap Context object with slots that closures point to
DynamicSloppy direct eval or with can change lookup at runtimeThe engine must keep names available by name and avoid many static assumptions
Top-level special caseScript and module top levelThe language exposes globals/modules beyond one stack frame, so engines use special storage
Register/stack, Context, or dynamic?
  • const rounded = Math.round(price) used only before the function returns
  • let count = 0 read and written by a returned increment function
  • A local beside strict direct eval("var hidden = 1")
  • A local visible to sloppy direct eval(code)
  • answer read inside with (box) { return answer; }
  • step parameter used by a nested inner function
Try it yourself
0 of 6 correct

Sort each variable or lookup by the storage decision an engine has to make.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

How closures change allocation

STEP THROUGH

A local variable normally dies when its function returns. A closure changes that if an inner function can read or write the local later. V8 creates a closure object that points to code and to the current Context, the heap object holding the captured slots it may need.

Analyzer replay: closure allocation
Step 0 of 6Ready
Your turn: follow the blue line

This replay is recorded from the lesson's static analyzer. Step through how declarations become stack/register or context variables before the program runs.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
  const scratch = label.trim();  let count = 0;  return function increment() {    count += 1;    return scratch + ":" + count;  };} const next = makeCounter(" jobs ");console.log(next());console.log(next());
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

Run the closure itself and you can see the result of that allocation decision: the returned function uses the same captured bindings on each call.

The closure output is realPop out in the code editor (opens in a new tab)JavaScript
function makeCounter(label) {  const scratch = label.trim();  let count = 0;  return function increment() {    count += 1;    return scratch + ":" + count;  };} const next = makeCounter(" jobs ");console.log(next());console.log(next());

V8 does not keep the whole outer scope alive just because a closure exists. It captures the variables the inner functions need. But all closures created by the same outer call can share one Context. If one slot contains a large object and any sibling closure remains reachable, that Context and the object remain reachable too.

Sibling closures share one outer ContextJavaScript
function buildWidgets() {  const bigConfig = { theme: "dark", cache: new Array(1000).fill("data") };  let clicks = 0;   function track() {    clicks += 1;    return clicks;  }   function readTheme() {    return bigConfig.theme;  }   return { track, readTheme };}

That is a practical memory-leak pattern: a listener, timer, or cache keeps one small closure reachable, and the shared Context keeps a big object reachable with it. The later Memory management and Finding memory leaks lessons show how to inspect retained objects.

Why eval and with defeat optimization

DYNAMIC

Static scope analysis works best when syntax tells the whole story. Sloppy direct eval and with break that rule. A sloppy direct eval string can read visible names by text and create a var binding in the caller. A with statement inserts an object into name lookup, so property names can shadow lexical declarations at runtime.

Analyzer replay: eval and with become dynamic
Step 0 of 4Ready
Your turn: follow the blue line

Dynamic features are the cases where a static scope tree is not enough. Step through why engines must be conservative.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
  let local = 1;  eval("var surprise = local");  return local;} function readWith(box) {  const answer = "lexical";  with (box) {    return answer;  }} console.log(readWith({ answer: "object wins" }));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Direct, strict, and indirect evalJavaScript
function sloppyDirect() {  const x = "local";  eval("var leaked = x");  return x + " | " + leaked;} function strictDirect() {  "use strict";  const x = "strict local";  eval("var hidden = 1");  return x + " | " + typeof hidden;} globalThis.x = "global";function indirectEval() {  const x = "local";  return (0, eval)("x");} console.log(sloppyDirect());console.log(strictDirect());console.log(indirectEval());
The tested output

The lesson tests run this as classic script code and prove the three lines are local | local, strict local | undefined, and global. Direct eval can see locals. Strict direct eval does not leak its var. Indirect eval runs globally.

with is a sloppy-only dynamic lookupJavaScript
function readWith(box) {  const answer = "lexical";  with (box) {    return answer;  }} console.log(readWith({ answer: "object wins" }));

Strict mode rejects with as a syntax error. Even in sloppy code, prefer explicit property access such as box.answer. It is clearer for humans and gives engines a stable binding story.

Context objects and context chains

V8 TERMS

The spec talks about lexical environments and environment-record chains. V8 uses compile-time Scope objects, serializes the needed metadata into ScopeInfo, and creates runtime Context objects when a scope needs context-allocated slots. Contexts link to previous contexts, forming a context chain.

Context chain classifier
Nested closures create a context chainPop out in the code editor (opens in a new tab)JavaScript
function outer() {  let total = 0;  return function middle(step) {    let label = "count";    return function inner() {      total += step;      return label + ":" + total;    };  };} const inner = outer()(2);console.log(inner());console.log(inner());
Captured bindings
totalfunction outer

referenced from an inner function, so it needs a heap Context slot

stepfunction middle

referenced from an inner function, so it needs a heap Context slot

labelfunction middle

referenced from an inner function, so it needs a heap Context slot

Try it yourself
Analyzer found 3 Context variables

inner reads bindings from both middle and outer, so the closure reaches through a chain of Context objects.

The table is static evidence from the same analyzer used in the playground.
Spec model vs V8 implementation terms
ThingWhat it meansWhy it matters
Spec lexical environmentA precise behavior model: an environment record plus an outer referenceExplains what JavaScript must do
V8 ScopeCompile-time parser structure that tracks declarations, references, and allocationUsed before bytecode is generated
V8 ScopeInfoRead-only heap metadata describing scope kind, names, and context layoutUsed for execution, debugging, and deoptimization
V8 ContextRuntime heap object with slots for context-allocated variablesWhat closures point to when captured variables must outlive a call
Registers/stack slotsFast frame storage for uncaptured locals and temporariesWhat bytecode such as Star/Ldar manipulates

The V8 faster class features blog also uses this terminology when private methods are accessed: V8 walks the context chain, reads a statically known slot from the found context, and uses that value to complete the access. The same idea matters for closure variables.

Bytecode evidence in Node 22.23.1 (V8 12.4)
ProbeOpcode names to look forInterpretation
Uncaptured localStar0, Ldar, arithmetic bytecodesThe value stays in registers/frame slots
Captured by same closure contextCreateFunctionContext, PushContext, LdaCurrentContextSlot, StaCurrentContextSlotThe outer function created a heap Context and the inner function reads/writes slots
Captured through an outer context chainLdaContextSlot, StaContextSlot, or immutable variants with a depthThe closure follows the context chain to a statically known slot
V8 bytecode probe commandShell
node --print-bytecode --print-bytecode-filter=inner captured.js
# Node 22.23.1 (V8 12.4): look for LdaCurrentContextSlot,
# StaCurrentContextSlot, LdaContextSlot, StaContextSlot, and PushContext.
Evidence, not address matching

V8 bytecode offsets and object addresses change. The tests assert only the presence of opcode names such as PushContext, LdaCurrentContextSlot, StaCurrentContextSlot, LdaContextSlot, and StaContextSlot for captured variables, plus register-style Star0 for an uncaptured local.

Captured variable probeJavaScript
function captured() {
  let x = 0;
  return function inner() {
    x += 1;
    return x;
  };
}
const f = captured();
f();
Uncaptured variable probeJavaScript
function uncaptured() {
  let x = 0;
  x += 1;
  return x;
}
uncaptured();
Outer context-chain probeJavaScript
function outer() {
  let x = 1;
  return function middle() {
    let y = 2;
    return function inner() {
      x += 1;
      y += 1;
      return x + y;
    };
  };
}
const inner = outer()();
inner();

DevTools and real-world memory

Chrome DevTools turns these ideas into clues. When paused, the Scope pane can show groups such as Local, Closure, Block, Script, and Global. Closure entries correspond to captured variables the debugger can expose. Values that were never captured or were optimized away may not be inspectable; DevTools wording can include unavailable or optimized out values depending on Chrome and optimization state.

DevTools note

Use the Scope pane to ask, “which variables are actually retained here?” Use heap snapshots for memory questions. The debugger is a view into optimized runtime state, not a promise that every source variable has a storage box you can inspect.

In production, this is why avoiding eval and with matters beyond style. Static names help engines optimize, and small captured state prevents accidental retention when a closure lives longer than expected.

Common misconceptions

“Every local variable is heap allocated.”

No. Uncaptured locals can stay in the function frame or bytecode registers.

“A closure keeps the whole outer scope alive.”

V8 tracks captured variables, not every variable. But sibling closures from one outer call can share a Context.

“eval is only slow because parsing a string costs time.”

Runtime parsing costs matter, but direct sloppy eval also forces conservative scope and name assumptions.

“Strict eval and indirect eval behave the same.”

Strict direct eval can read caller locals but keeps its own declarations. Indirect eval runs as global code.

“DevTools scope labels are exact memory diagrams.”

They are debugging views over optimized engine state, not a literal storage map.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upPredict static output

Run the static-resolution function mentally and type the printed value.

Starter codePop out in the code editor (opens in a new tab)JavaScript
function priceLabel(price) {
  const currency = "₹";
  const rounded = Math.round(price);
  return currency + rounded;
}

console.log(priceLabel(19.4));

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeFind the Context variable

    Which variable must be context-allocated?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    function makeCounter() {
      let count = 0;
      return function next() {
        count += 1;
        return count;
      };
    }
    const next = makeCounter();
    console.log(next(), next());

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeStrict eval containment

      What word proves strict direct eval did not leak its var?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      function sloppyDirect() {
        const x = "local";
        eval("var leaked = x");
        return x + " | " + leaked;
      }
      
      function strictDirect() {
        "use strict";
        const x = "strict local";
        eval("var hidden = 1");
        return x + " | " + typeof hidden;
      }
      
      globalThis.x = "global";
      function indirectEval() {
        const x = "local";
        return (0, eval)("x");
      }
      
      console.log(sloppyDirect());
      console.log(strictDirect());
      console.log(indirectEval());

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeTrace the context chain

        Type the two output lines separated by a comma.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        function outer() {
          let total = 0;
          return function middle(step) {
            let label = "count";
            return function inner() {
              total += step;
              return label + ":" + total;
            };
          };
        }
        
        const inner = outer()(2);
        console.log(inner());
        console.log(inner());

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeRemove dynamic lookup

          Which feature should you remove so lookup is static again?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          function readAnswer(box) {
            const fallback = "lexical";
            with (box) {
              return answer || fallback;
            }
          }

          Answer, then press Check. Spacing and letter case don’t matter.

            Quiz

            8 QUESTIONS
            Scope analysis quiz · 8 questionsScore: first tries count
            1. Question 1 of 8What does scope analysis do before a function runs?

              Choose an answer to see the explanation.

            2. Question 2 of 8What does this print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              function priceLabel(price) {
                const currency = "₹";
                const rounded = Math.round(price);
                return currency + rounded;
              }
              
              console.log(priceLabel(19.4));

              Choose an answer to see the explanation.

            3. Question 3 of 8When does V8 need a heap Context slot for a local variable?

              Choose an answer to see the explanation.

            4. Question 4 of 8What does the closure output snippet print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              function makeCounter() {
                let count = 0;
                return function next() {
                  count += 1;
                  return count;
                };
              }
              const next = makeCounter();
              console.log(next(), next());

              Choose an answer to see the explanation.

            5. Question 5 of 8Which statement about strict direct eval is correct?

              Choose an answer to see the explanation.

            6. Question 6 of 8What does indirect eval read here?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              globalThis.x = "global";
              function demo() {
                const x = "local";
                return (0, eval)("x");
              }
              console.log(demo());

              Choose an answer to see the explanation.

            7. Question 7 of 8Which bytecode names are evidence that a closure variable is context-allocated in Node 22.23.1 / V8 12.4?

              Choose an answer to see the explanation.

            8. Question 8 of 8Why can one closure keep a large object alive for sibling closures from the same outer call?

              Choose an answer to see the explanation.

            Key takeaways

            • Lexical scoping lets engines resolve most names statically from the scope tree.
            • Uncaptured locals can live in fast stack/frame/register storage.
            • Variables captured by inner functions become context-allocated in heap Context slots.
            • Sloppy direct eval and with make lookup dynamic and force conservative optimization.
            • ScopeInfo describes context layout; a runtime Context stores captured values.

            Final definition: scope analysis is the engine's pre-execution binding and allocation plan for variables.

            Up next: Bytecode & interpreters, where those storage decisions show up as bytecode instructions.

            CompleteFrontend Clear concepts. Working examples.