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.
- 01Read a scope treeExplain how parsers bind identifier references to declarations before code runs.
- 02Predict allocationClassify locals as stack/register, context-allocated, or dynamic.
- 03Connect 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.
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?
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 ANALYZERLexical 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.
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.
function makeCounter() { let count = 0; const label = "jobs"; return function next() { count += 1; return label + ":" + count; }; }
- script script
- function makeCounter function
- function next function
makeCounterglobal/top-leveltop-level binding; engines handle top-level script/module storage speciallycountContextreferenced from an inner function, so it needs a heap Context slotlabelContextreferenced from an inner function, so it needs a heap Context slotnextstack/registernot captured by an inner function; it can stay in the function frame/registers
The analyzer parses your code with acorn, builds scopes, resolves identifiers, and classifies declared variables without executing the program.
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
SORTERV8'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.
| Engine decision | When it applies | What the learner should picture |
|---|---|---|
| Stack/register | A variable is local to one function activation and not captured by an inner function | Fast storage in the current frame; it disappears when the call returns |
| Context-allocated | An inner function can use the variable after the outer call returns | A heap Context object with slots that closures point to |
| Dynamic | Sloppy direct eval or with can change lookup at runtime | The engine must keep names available by name and avoid many static assumptions |
| Top-level special case | Script and module top level | The language exposes globals/modules beyond one stack frame, so engines use special storage |
const rounded = Math.round(price)used only before the function returnslet count = 0read and written by a returnedincrementfunction- A local beside strict direct
eval("var hidden = 1") - A local visible to sloppy direct
eval(code) answerread insidewith (box) { return answer; }stepparameter used by a nestedinnerfunction
Sort each variable or lookup by the storage decision an engine has to make.
How closures change allocation
STEP THROUGHA 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.
This replay is recorded from the lesson's static analyzer. Step through how declarations become stack/register or context variables before the program runs.
script
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());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.
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.
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
DYNAMICStatic 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.
Dynamic features are the cases where a static scope tree is not enough. Step through why engines must be conservative.
script
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" }));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 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.
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 TERMSThe 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.
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());function outerreferenced from an inner function, so it needs a heap Context slot
function middlereferenced from an inner function, so it needs a heap Context slot
function middlereferenced from an inner function, so it needs a heap Context slot
inner reads bindings from both middle and outer, so the closure reaches through a chain of Context objects.
| Thing | What it means | Why it matters |
|---|---|---|
| Spec lexical environment | A precise behavior model: an environment record plus an outer reference | Explains what JavaScript must do |
V8 Scope | Compile-time parser structure that tracks declarations, references, and allocation | Used before bytecode is generated |
V8 ScopeInfo | Read-only heap metadata describing scope kind, names, and context layout | Used for execution, debugging, and deoptimization |
V8 Context | Runtime heap object with slots for context-allocated variables | What closures point to when captured variables must outlive a call |
| Registers/stack slots | Fast frame storage for uncaptured locals and temporaries | What 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.
| Probe | Opcode names to look for | Interpretation |
|---|---|---|
| Uncaptured local | Star0, Ldar, arithmetic bytecodes | The value stays in registers/frame slots |
| Captured by same closure context | CreateFunctionContext, PushContext, LdaCurrentContextSlot, StaCurrentContextSlot | The outer function created a heap Context and the inner function reads/writes slots |
| Captured through an outer context chain | LdaContextSlot, StaContextSlot, or immutable variants with a depth | The closure follows the context chain to a statically known slot |
node --print-bytecode --print-bytecode-filter=inner captured.js
# Node 22.23.1 (V8 12.4): look for LdaCurrentContextSlot,
# StaCurrentContextSlot, LdaContextSlot, StaContextSlot, and PushContext.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.
function captured() {
let x = 0;
return function inner() {
x += 1;
return x;
};
}
const f = captured();
f();function uncaptured() {
let x = 0;
x += 1;
return x;
}
uncaptured();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.
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 EXERCISESRun the static-resolution function mentally and type the printed value.
function priceLabel(price) {
const currency = "₹";
const rounded = Math.round(price);
return currency + rounded;
}
console.log(priceLabel(19.4));The function prints ₹19. currency and rounded are ordinary locals that can be resolved statically.
Which variable must be context-allocated?
function makeCounter() {
let count = 0;
return function next() {
count += 1;
return count;
};
}
const next = makeCounter();
console.log(next(), next());count is captured by next, so it needs Context storage. The two calls print 1 2.
What word proves strict direct eval did not leak its var?
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());typeof hidden is undefined outside the strict eval string, so the middle output is strict local | undefined.
Type the two output lines separated by a comma.
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());The two calls print count:2 and count:4. inner reaches total, step, and label through its Context chain.
Which feature should you remove so lookup is static again?
function readAnswer(box) {
const fallback = "lexical";
with (box) {
return answer || fallback;
}
}function readAnswer(box) {
const fallback = "lexical";
return box.answer || fallback;
}Removing with makes the source of answer explicit. The engine can now resolve fallback lexically and box.answer as a property access.
Quiz
8 QUESTIONSQuestion 1 of 8What does scope analysis do before a function runs?
Choose an answer to see the explanation.
Question 2 of 8What does this print?
Read the code, then predictfunction priceLabel(price) { const currency = "₹"; const rounded = Math.round(price); return currency + rounded; } console.log(priceLabel(19.4));Choose an answer to see the explanation.
Question 3 of 8When does V8 need a heap
Contextslot for a local variable?Choose an answer to see the explanation.
Question 4 of 8What does the closure output snippet print?
Read the code, then predictfunction 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.
Question 5 of 8Which statement about strict direct eval is correct?
Choose an answer to see the explanation.
Question 6 of 8What does indirect eval read here?
Read the code, then predictglobalThis.x = "global"; function demo() { const x = "local"; return (0, eval)("x"); } console.log(demo());Choose an answer to see the explanation.
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.
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
Contextslots. - Sloppy direct
evalandwithmake lookup dynamic and force conservative optimization. ScopeInfodescribes context layout; a runtimeContextstores 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.