Variable scope, closure
JavaScript leans hard on functions. You can build a function at any moment, hand it to another function as an argument, stash it in an object, and call it much later from a completely different part of your program. That freedom raises questions that most other topics let you ignore.
You already know a function can read variables declared outside of it — its “outer” variables. But two situations get slippery fast:
- If an outer variable changes after the function was created, does the function see the fresh value or a stale copy?
- If a function is carried off and called somewhere else entirely, does it still reach the outer variables from where it was written, or the ones where it now runs?
The answers are consistent and, once you see the mechanism, obvious. This article builds that mechanism up piece by piece.
Code blocks
Declare a variable inside a block {...} and it lives only inside that block.
{
// do some work with local variables that shouldn't leak out
let message = "Hello"; // visible only in this block
alert(message); // Hello
}
alert(message); // Error: message is not defined
That gives you a clean way to fence off a chunk of work along with the variables it needs, so nothing spills into the surrounding code:
{
// show one message
let message = "Hello";
alert(message);
}
{
// show a different message
let message = "Goodbye";
alert(message);
}
Each block gets its own message, and they never collide.
The same block-scoping applies to if, for, while, and friends. Variables born inside their {...} stay inside:
if (true) {
let phrase = "Hello!";
alert(phrase); // Hello!
}
alert(phrase); // Error, no such variable!
Once the if finishes, its block is gone and so is phrase. That’s a feature: you can spin up a variable specific to one branch without worrying it’ll haunt the rest of your function.
Loops work the same way:
for (let i = 0; i < 3; i++) {
// i is visible only inside this loop
alert(i); // 0, then 1, then 2
}
alert(i); // Error, no such variable
Look closely and let i sits outside the braces, in the loop header. The for statement is a special case: the variable declared in its header counts as part of the loop’s block, so it disappears when the loop ends.
Nested functions
A function is nested when it’s created inside another function. JavaScript makes this trivial, and it’s a normal thing to do.
One common use is organizing code — a little helper that only the outer function needs:
function welcomeGuest(firstName, lastName) {
// nested helper used just below
function fullName() {
return firstName + " " + lastName;
}
alert( "Welcome, " + fullName() );
alert( "Enjoy your stay, " + fullName() );
}
Here fullName() reaches out and reads firstName and lastName from the call it lives in. That’s the ordinary outer-variable access you already know.
The interesting part comes when a nested function leaves home. You can return it — as a standalone value or as a property of an object — and call it from anywhere. Wherever it ends up, it still reaches the same outer variables it was created with.
makeStepCounter builds a step-counter function that hands back the next number each time you call it:
function makeStepCounter() {
let steps = 0;
return function() {
return steps++;
};
}
let stepper = makeStepCounter();
alert( stepper() ); // 0
alert( stepper() ); // 1
alert( stepper() ); // 2
Here it is running for real. The steps variable was created once, inside a makeStepCounter() call that finished long ago — yet every click reads and advances the very same one:
Small as it is, close cousins of this pattern show up in real code — for example as the core of a pseudorandom number generator that feeds repeatable values into automated tests.
The stepper function keeps returning a fresh number even though makeStepCounter already finished running. Where does steps live now? If you built two step counters, would they share that variable or each get their own? To answer confidently instead of guessing, you need the machinery underneath. That’s next.
Lexical Environment
The explanation comes in four steps.
Step 1. Variables
Every running function, every code block {...}, and the script as a whole carries a hidden internal object called its Lexical Environment.
That object has two parts:
- An Environment Record — an object holding all the local variables as properties (plus a few extras, like the value of
this). - A reference to the outer Lexical Environment — the one belonging to the surrounding code.
So a “variable” isn’t a mystical thing. It’s a property on the Environment Record. Reading or changing a variable means reading or changing a property on that internal object.
A script with no functions has exactly one Lexical Environment — the global one:
The rectangle is the Environment Record (the variable store); the arrow is the outer reference. The global environment has no outer scope, so its arrow points at null.
As the code runs, that environment changes. Walk through this script line by line:
let phrase; // (1) declared, uninitialized
phrase = "Hello"; // (2) assigned
phrase = "Bye"; // (3) reassigned
The Environment Record evolves as execution proceeds:
Reading the stages:
- When the script starts, the environment is pre-loaded with every declared variable. They begin in an “uninitialized” state — the engine knows the name exists, but you can’t touch it yet. Referencing it now throws. This gap between “name reserved” and “declaration reached” is the temporal dead zone.
- Execution reaches
let phrase. No value was assigned, so it’s nowundefined. From here on you can use it. phrasegets"Hello".phraseis reassigned to"Bye".
Two takeaways, kept short:
- A variable is a property of the internal object tied to the currently running block, function, or script.
- Working with a variable is working with a property on that object.
Step 2. Function Declarations
A function is a value too, much like a variable’s value. But declarations get special treatment.
A Function Declaration is fully initialized the moment its Lexical Environment is created.
When the environment comes into being, any Function Declaration inside it is already a ready-to-call function — unlike a let, which sits uninitialized until execution reaches its line. That’s why you can call a declared function on a line above where it’s written:
sayHi("Maya"); // works, even though the declaration is below
function sayHi(name) {
alert(`Hello, ${name}`);
}
This early-readiness applies only to Function Declarations. A Function Expression assigned to a variable — let say = function(name) {...} — follows the ordinary variable rules and isn’t ready until execution reaches that line.
Step 3. Inner and outer Lexical Environment
When a function is called, a brand-new Lexical Environment is created at the start of the call to hold its parameters and local variables.
Take this code, paused at the alert line inside say:
let phrase = "Hello";
function say(name) {
alert( `${phrase}, ${name}` );
}
say("Maya"); // Hello, Maya
During the call there are two environments in play:
- The inner environment belongs to this run of
say. It holds one thing:name, the argument, set to"Maya". - The outer environment is the global one, holding
phraseand thesayfunction itself. - The inner environment carries a reference to the outer one.
To resolve a variable, the engine checks the inner environment first, then the outer, then the next one out, and so on up to the global environment.
If the name isn’t found anywhere, that’s an error in strict mode. (Without use strict, assigning to an undeclared name quietly creates a global variable — a compatibility wart from old code.)
For the alert inside say:
nameis found right away in the inner environment.phraseisn’t local, so the engine follows the outer reference and finds it in the global environment.
Step 4. Returning a function
Back to makeStepCounter:
function makeStepCounter() {
let steps = 0;
return function() {
return steps++;
};
}
let stepper = makeStepCounter();
Each call to makeStepCounter() creates a fresh Lexical Environment holding that run’s variables — here, steps. So while makeStepCounter runs, you have two nested environments, exactly like the say example.
The twist: during that run, a tiny one-line function return steps++ is created but not called yet. Just built.
Now the crucial rule. Every function remembers the Lexical Environment it was created in. No magic — every function carries a hidden property called [[Environment]] that references the environment where the function was born:
So stepper.[[Environment]] holds a reference to the {steps: 0} environment. That’s how the function remembers where it came from, regardless of where it eventually runs. This reference is fixed at creation time — set once, never changed.
When you finally call stepper(), a new environment is created for that call. Its outer reference isn’t taken from wherever the call happens to sit — it’s taken from stepper.[[Environment]]:
Inside stepper(), the code needs steps. It searches its own environment first — empty, no locals there — then follows the outer link into the makeStepCounter() environment, where it finds steps and increments it.
A variable is always updated in the environment where it actually lives.
After the call:
Call stepper() again and steps climbs to 2, then 3 — always in that same environment. Build a second step counter with another makeStepCounter() call and it gets its own separate environment with its own steps. The two never interfere. That independence is the answer to the question from earlier.
See it for yourself: both buttons below run the same returned function code, but each was built by its own makeStepCounter() call, so each owns a private steps. Click them in any order and watch the numbers stay completely separate:
The captured value doesn’t have to be a counter. A factory like makeMultiplier(n) bakes n into each function it returns — and every returned function keeps its own n forever. Type a number, then let three closures born with different n values each transform it:
Garbage collection
Normally, a function’s Lexical Environment is thrown away as soon as the call finishes — nothing references it anymore, and like every JavaScript object it only survives while something reachable points at it.
Closures change that. If a nested function is still reachable after the outer function returns, its [[Environment]] keeps a live reference to the outer environment. So that environment — and every variable in it — stays alive:
function f() {
let value = 123;
return function() {
alert(value);
}
}
let g = f(); // g.[[Environment]] keeps the f() environment alive
Call f() several times and hold onto each returned function, and each run’s environment is retained independently. All three below stay in memory:
function f() {
let value = Math.random();
return function() { alert(value); };
}
// three functions in an array, each linked to its own f() environment
let arr = [f(), f(), f()];
An environment dies once it becomes unreachable — same rule as any object. It sticks around only while at least one nested function still references it. Drop that last reference and the environment (with its value) is collected:
function f() {
let value = 123;
return function() {
alert(value);
}
}
let g = f(); // while g exists, value stays in memory
g = null; // ...reference gone, memory can be reclaimed
Real-life optimizations
In theory, as long as a closure is alive, all of its outer variables are retained. In practice, engines are smarter than that. They inspect which variables the closure actually uses, and if an outer variable is provably never referenced, they drop it.
In V8 (Chrome, Edge, Opera) this optimization has a visible side effect: the discarded variable becomes unavailable in the debugger.
Open Chrome DevTools and run this. When it pauses at debugger, type alert(value) in the console:
function f() {
let value = Math.random();
function g() {
debugger; // in console: alert(value) → No such variable!
}
return g;
}
let g = f();
g();
The variable is gone. g never uses value, so V8 optimized it away — even though the spec says it should be reachable.
This can produce genuinely confusing debugging moments. A nastier version: the optimized-out local is missing, so a lookup instead finds a same-named variable further out and shows you that one:
let value = "the far one";
function f() {
let value = "the near one";
function g() {
debugger; // in console: alert(value) → "the far one"
}
return g;
}
let g = f();
g();
You expected "the near one" and got the global "the far one", because the inner value was discarded and the search kept climbing.
Worth filing away: if you debug in Chrome, Edge, or Opera, you’ll hit this eventually. It’s not a bug in the debugger — it’s a deliberate V8 optimization, and it may change in the future. When in doubt, run the examples on this page to check the current behavior yourself.