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 the engine links each name to the variable it means, before the code runs.
- 02Predict where a variable is keptSay whether a variable stays on the stack, goes into a Context, or is dynamic because of eval or with.
- 03Spot the engine's cluesRecognize V8's Context and ScopeInfo, the bytecode that reads Context slots, and the DevTools Scope pane.
Where variables live is decided early
The engine is the program inside a browser or Node.js that runs your JavaScript. Before it runs a function, the engine reads the code once. During that first read, it does a job called scope analysis:
- It finds every variable the code makes. Making a variable is called a declaration, like
const currency = "₹". - It finds every place the code uses a name. Each use is called a reference.
- It links each reference to the declaration it means.
- It decides where each variable will be kept while the code runs.
A part of the code where a name can be used is called a scope. A function is one scope. A block in curly braces, { }, is another. Scopes sit inside each other, like boxes inside boxes. Together they form a scope tree.
Earlier lessons covered Scope, Closures, Execution contexts, Hoisting, Stack & heap, eval, and Strict mode. Those lessons showed what JavaScript does. This lesson shows how the engine plans for it before the code runs.
Scope analysis answers two questions before a function runs. First: which variable does each name mean? Second: can each variable be thrown away when the call ends, or must it be kept for later? A kept variable goes into a heap object that V8 calls a Context. You'll meet it below.
While you work, you write quick notes in your own notebook. You clear it after class. If a classmate needs one of your notes after you leave, you copy that note into the shared class notebook. If the class list keeps changing, you can't plan who will need what. So you keep everything in the shared notebook.
- In real life: Your desk notebook, cleared after class
- In JavaScript: A variable that is thrown away when the call ends
- In real life: The shared class notebook anyone can reopen
- In JavaScript: A
Contextslot that a closure can use later - In real life: A class list that keeps changing
- In JavaScript: Names that
evalorwithcan change while the code runs
Linking names before the code runs
LIVE ANALYZERJavaScript uses lexical scope. That means a name's meaning comes from where the code is written, not from when it runs. So the engine can link most names just by reading the code.
The part of the engine that reads code text is called the parser. It makes a scope for each of these:
- a function
- a block in curly braces
- a
catch (error)part of atry...catch - a module, which is a file that uses
importorexport - a class
Then it takes each name the code uses. It starts in the scope where the name is used and looks outward, one scope at a time, until it finds the declaration. If it finds none, it marks the name as global or not found.
function priceLabel(price) { const currency = "₹"; const rounded = Math.round(price); return currency + rounded;} console.log(priceLabel(19.4));- Line 1 makes the function
priceLabelwith one parameter,price. - Line 2 makes
currency. - Line 3 makes
roundedby roundingprice.Math.round(19.4)is19. - Line 4 joins
currencyandrounded. - Line 7 calls the function and prints
₹19.
The engine doesn't have to guess what price, currency, and rounded mean. It links each one to its 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
makeCountertop levela top-level variable; engines keep the top level of a script or module in a special placecountContextan inner function uses it, so it needs a slot in a heap ContextlabelContextan inner function uses it, so it needs a slot in a heap Contextnextstackno inner function uses it, so it can stay in the call's frame or in registers
The analyzer reads your code with acorn, builds the scopes, links each name to its variable, and colors each variable by where it is kept. It never runs your code.
This playground is a small analyzer made for this lesson. It is much simpler than V8, the engine inside Chrome and Node.js, but it is real code. It uses two libraries, acorn and acorn-walk, to read your code, build the scope tree, link each name, and sort the variables. It never runs your code.
Two places to keep a variable
SORTEROnce every name is linked, the engine decides where each variable lives. There are two main places:
- The stack is fast, short-lived memory. Each function call gets its own area on it, called a frame. V8 also uses registers: small, fast slots for the values a call is working on. Everything in a frame is thrown away when the call returns.
- The heap is memory for things that must last longer. V8 keeps such variables in a heap object called a
Context. Each kept variable gets its own numbered place in it, called a slot.
A variable used only during the call can stay on the stack. A variable that an inner function uses needs a Context slot, because the inner function might run after the outer call has returned. Putting a variable in a Context is called context allocation.
The V8 team calls this the "variable allocation problem" in the blog post Blazingly fast parsing, part 2: lazy parsing. For each variable an inner function uses, the engine must know: is it on the stack, or in a Context? V8's runtime scope docs name three objects that take part:
Scope: what the parser builds while it reads the code.ScopeInfo: a short saved description of a scope and its slots.Context: the heap object that holds the kept values while the code runs.
The table lists the four kinds of decision. Read each row as: "when this is true, the engine picks this place, and this is what to picture."
| Where it goes | When this happens | What to picture |
|---|---|---|
| Stack | The variable belongs to one function call, and no inner function uses it. | Fast space in the call's frame. It is thrown away when the call returns. |
| Context | An inner function can use the variable after the outer call returns. | A heap object called a Context, with one slot per kept variable. The inner function links to it. |
| Dynamic | Direct eval can read names from a string at run time, or with can change what a name means. | The engine keeps those names findable by name, and skips some speed-ups. |
| Top level | The variable is at the top of a script or module, outside every function. | It lasts as long as the page or program, so engines keep it in a special place, not in one call's frame. |
Some names can't be planned ahead. When eval or with is involved, the engine only finds out while the code runs what a name means. This lesson calls those names dynamic. You'll see why in the section on eval and with.
const rounded = Math.round(price), used only before the function returnslet count = 0, read and changed by a returnedincrementfunction- A local variable next to a strict direct
eval("var hidden = 1") - A local variable that a non-strict direct
eval(code)can see answerread insidewith (box) { return answer; }- The
stepparameter, used by a functioninnermade insidemiddle
Sort each card by where the engine has to keep the variable.
How closures change where variables live
STEP THROUGHA local variable normally goes away when its function returns. A closure changes that. A closure is a function made inside another function that still uses the outer function's variables. If the inner function can read or change a variable later, that variable must be kept.
So V8 gives the inner function two links: one to its code, and one to the outer call's Context. The Context holds only the variables that must be kept.
This replay is recorded from the lesson's own analyzer. Step through how each variable is put on the stack or in a Context 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());Step through the replay above. It shows what the analyzer decides for each variable:
labelis used only on line 2, insidemakeCounter. It can stay on the stack.scratchis used byincrementon line 6. It goes into the Context.countis read and changed byincrementon line 5. It goes into the Context too.
label is used only while makeCounter runs, so it can go when the call ends. increment() uses scratch and count later, so they move into a Context that stays.Now run the same code. The function you get back uses the same kept variables on every 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());- Line 10 calls
makeCounteronce.label.trim()on line 2 removes the spaces, soscratchis"jobs". The returnedincrementfunction is saved asnext. - Line 11 prints
jobs:1. - Line 12 prints
jobs:2.
Both calls change the same count, because it lives in the Context, not in one call's frame.
V8 doesn't keep every outer variable just because a closure exists. It keeps only the variables the inner functions use. But all closures made by the same outer call share one Context. If one slot holds a big object, and any of those closures is still in use, the Context and the big object stay in memory.
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 };}- Line 2 makes
bigConfig, which holds an array of 1000 strings. track, on lines 5 to 8, uses onlyclicks.readTheme, on lines 10 to 12, uses onlybigConfig.- Line 14 returns both functions.
Both functions come from the same call to buildWidgets, so they share one Context. That Context holds clicks and bigConfig. While track is still in use, bigConfig stays in memory too, even after readTheme is gone.
This is a common cause of a memory leak: memory your program keeps but no longer needs. An event listener, a timer, or a cache keeps one small closure in use, and the shared Context keeps a big object with it. The later Memory management and Finding memory leaks lessons show how to find what is being kept.
Why eval and with get in the way
DYNAMICScope analysis works best when the written code tells the whole story. Two features break that rule:
eval(text)runs a string as JavaScript code. When you call it by its own name,eval(...), it is a direct eval. The string can then use the variables around the call.with (object) { ... }makes the object's properties act like variables inside the block.
Code without "use strict" is called non-strict code. In non-strict code, a direct eval string can read any variable it can see, and it can even make a new var variable in the calling function. The engine can't know any of that until the string arrives at run time.
A with statement has the same problem. An object property can hide a variable with the same name, and the engine only finds out while the code runs. In both cases the engine must play safe: it keeps the names findable by name and skips some speed-ups.
eval and with are the cases where reading the code isn't enough. Step through why the engine has to play safe.
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" }));The next example tries three ways to call eval:
sloppyDirectuses direct eval in non-strict code.strictDirectuses direct eval in strict code, after"use strict".indirectEvalwrites(0, eval). The comma hands back the plainevalfunction, so the call is no longer direct. An indirect eval runs the string at the top level of the script, outside every function.
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 code as a normal script. It prints three lines. First, local | local: direct eval can see local variables. Second, strict local | undefined: strict direct eval keeps its var hidden to itself. Third, global: indirect eval runs at the top level and sees the global x.
Strict mode stops eval from adding variables to the calling function. It doesn't stop eval from reading them by name: the string can still name any local variable it can see. So in the tested version of V8 (12.4), that local variable is kept in a Context slot. Other engines may store it in a different way.
function readWith(box) { const answer = "lexical"; with (box) { return answer; }} console.log(readWith({ answer: "object wins" }));- Line 2 makes a variable called
answer, holding"lexical". - Line 3 starts a
with (box)block. - On line 4,
answeris looked up as a property ofboxfirst. - Line 8 passes an object that has an
answerproperty, so it printsobject wins.
with (box), the name answer is looked up in the box object first. The object has answer, so the function returns "object wins" and never reaches the variable.Strict mode doesn't allow with at all: it is a syntax error. Even in non-strict code, write the property access yourself, like box.answer. People can read it more easily, and the engine can plan for it.
Context objects and context chains
V8 TERMSThe specification is the document that defines JavaScript. It describes each scope as a lexical environment: a list of variables plus a link to the scope outside it. Following those links outward gives a chain of scopes.
V8 does the same work with its own objects:
- While it reads the code, it builds a
Scopefor each scope. - It saves what it needs later in a
ScopeInfo. - While the code runs, it makes a
Contextfor each scope that has kept variables. - Each Context links to the Context outside it. Those links form the 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 outeran inner function uses it, so it needs a slot in a heap Context
function middlean inner function uses it, so it needs a slot in a heap Context
function middlean inner function uses it, so it needs a slot in a heap Context
inner uses variables from both middle and outer. So it reaches them through a chain of Context objects.
innerusestotal, whichoutermakes on line 2.- It also uses
stepandlabel, whichmiddlemakes on lines 3 and 4. - Line 12 calls
outer(), then calls the returnedmiddlewith2. - Lines 13 and 14 print
count:2and thencount:4.
inner() keeps a link to the Context made by middle(2), which holds step and label. That Context links on to the one made by outer(), which holds total.The table puts the specification's words next to V8's words. Read each row as: "this term, what it is, and why it matters."
| Term | What it is | Why it matters |
|---|---|---|
| Lexical environment (specification) | The specification's model of a scope: a list of variables plus a link to the scope outside it. | It says what JavaScript must do when it looks up a name. |
Scope (V8) | What V8's parser builds while it reads the code. It lists declarations, uses of names, and where each variable goes. | V8 uses it before it makes any bytecode. |
ScopeInfo (V8) | A short saved description of a scope: its kind, its names, and its Context slots. | V8 uses it when the code runs, when you debug, and when it undoes a speed-up. |
Context (V8) | A heap object, made while the code runs, with one slot per kept variable. | Closures link to it, so kept variables last after the call returns. |
| Registers and stack slots | Fast space in a call's frame, for variables that aren't kept and for in-between values. | Bytecode such as Star and Ldar stores and loads these values. |
The V8 blog post on faster class features uses the same words for private methods. V8 follows the context chain, reads a slot whose number it already knows, and uses that value. Variables kept for closures are found the same way.
You can see these choices in V8's bytecode. Bytecode is the list of small instructions V8 makes from your code before it runs it. Each instruction has a name, called an opcode:
- Opcodes with
ContextSlotin the name read or write a Context slot. PushContextmakes a Context the current one.Star0stores a value in register 0, on the stack side.
The table lists what to look for. Read each row as: "for this kind of variable, look for these opcodes; they tell you this."
| Kind of variable | Opcodes to look for | What they tell you |
|---|---|---|
| A variable no inner function uses | Star0, Ldar, and math opcodes | The value stays in registers, in the call's frame. |
| A variable kept for a closure | CreateFunctionContext, PushContext, LdaCurrentContextSlot, StaCurrentContextSlot | The outer function made a Context, and the inner function reads and writes its slots. |
| A variable next to strict direct eval | CreateFunctionContext, StaCurrentContextSlot | In the tested V8 version, the variable goes into a Context, so the eval string can read it by name. |
| A variable from further out on the chain | LdaContextSlot and StaContextSlot (or the read-only versions), with a depth | The closure goes a known number of steps out the context chain, then reads a known slot. |
To print the bytecode yourself, save one of the probe programs below as a file, then run this command in a terminal. --print-bytecode-filter=inner prints only the function called inner.
node --print-bytecode --print-bytecode-filter=inner captured.js
# Node 22 / V8 12.4: look for LdaCurrentContextSlot,
# StaCurrentContextSlot, LdaContextSlot, StaContextSlot, and PushContext.Bytecode positions and memory addresses change between runs. So the lesson tests only check that some opcode names appear. For kept variables they look for PushContext, LdaCurrentContextSlot, StaCurrentContextSlot, LdaContextSlot, and StaContextSlot. For a variable that isn't kept, they look for Star0. The strict direct-eval probe also makes a Context, so the eval string can read local by name.
Each probe is a tiny program for one case.
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 strictEval() {
"use strict";
let local = 1;
eval("local");
return local;
}
strictEval();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();Seeing this in DevTools
Chrome DevTools lets you look at these ideas. When your code is paused at a breakpoint, the Scope pane lists the variables you can see, in groups:
- Local: the variables of the function that is running.
- Closure: outer variables kept in a Context for this function.
- Block:
letandconstvariables of the current block. - Script:
letandconstvariables at the top level of the script. - Global: the global variables.
Some variables may not show a value. A variable that was never kept, or that the engine removed to save work, may show as unavailable or "optimized out". The exact words depend on the Chrome version.
Use the Scope pane to ask: "Which variables are really kept here?" For memory questions, use a heap snapshot: a list of everything in memory at one moment. The Scope pane shows what the engine can show you. It doesn't promise a value for every variable in your code.
In real projects, avoiding eval and with matters for more than style:
- Names the engine can link early let it make your code faster.
- Keeping closures small stops a long-lived closure from holding a big object by accident.
Common misconceptions
"Every local variable lives on the heap."
No. A local that no inner function uses can stay in the call's frame or in registers.
"A closure keeps every outer variable alive."
No. V8 keeps only the variables inner functions use. But closures made by the same outer call share one Context, so one closure can keep another's variables alive.
"eval is slow only because reading the string takes time."
Reading the string does take time. But non-strict direct eval also forces the engine to play safe with every name it could reach.
"Strict eval and indirect eval do the same thing."
No. Strict direct eval can read the caller's local variables but keeps its own variables to itself. Indirect eval runs at the top level and can't see local variables at all.
"The DevTools Scope pane is an exact map of memory."
No. It shows what the engine can show you, after its speed-ups. It is not a list of where every value is stored.
Practice exercises
5 EXERCISESWork through the function in your head and type what it prints.
function priceLabel(price) {
const currency = "₹";
const rounded = Math.round(price);
return currency + rounded;
}
console.log(priceLabel(19.4));It prints ₹19. currency and rounded are normal local variables, and the engine links them before the code runs.
Which variable must be kept in a Context?
function makeCounter() {
let count = 0;
return function next() {
count += 1;
return count;
};
}
const next = makeCounter();
console.log(next(), next());count is used by next, so it must be kept in a Context slot. The two calls print 1 2.
Which word in the output shows that strict direct eval didn't add its var to the function?
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());Outside the eval string, typeof hidden is undefined. So the middle line is strict local | undefined.
Type the two printed lines, with a comma between them.
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 the engine can link every name before the code runs?
function readAnswer(box) {
const fallback = "lexical";
with (box) {
return answer || fallback;
}
}function readAnswer(box) {
const fallback = "lexical";
return box.answer || fallback;
}Without with, it is clear where answer comes from. The engine links fallback before the code runs, and box.answer is a normal property read.
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 this counter 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 sentence about strict direct eval is true?
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 8In Node 22 (V8 12.4), which bytecode names show that a closure's variable is kept in a Context?
Choose an answer to see the explanation.
Question 8 of 8Why can one closure keep a big object in memory, even if only another closure from the same call uses it?
Choose an answer to see the explanation.
Key takeaways
- Because of lexical scope, the engine can link most names to their variables just by reading the code.
- A local variable that no inner function uses can stay on the stack, in the call's frame or in registers.
- A variable that an inner function uses is kept in a slot of a heap object called a
Context. - Non-strict direct
evalandwithmake names unknown until run time, so the engine has to play safe. ScopeInfodescribes which slots a Context has; theContextholds the values while the code runs.
Final definition: scope analysis is the engine's plan, made before the code runs, for what each name means and where each variable is kept.
Up next: Bytecode & interpreters, where you'll see these choices turn into bytecode instructions.