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.

global scopelet app = “…”block { … }let message = “Hello”✓ sees app ✓ sees messageif (true) { … }✓ sees app, message, and its own vars
Each pair of braces defines a scope. Inner code sees outward; outer code cannot see in.

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:

interactiveA step counter that remembers

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 &#123;...&#125;, and the script as a whole carries a hidden internal object called its Lexical Environment.

That object has two parts:

  1. An Environment Record — an object holding all the local variables as properties (plus a few extras, like the value of this).
  2. 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:

global Environment Recordphrase: “Hello”nullouter
The global Lexical Environment. Its outer reference points to null, because nothing surrounds the script.

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:

script start
phrase: <uninitialized>
let phrase;
phrase: undefined
phrase = “Hello”;
phrase: “Hello”
phrase = “Bye”;
phrase: “Bye”
How the global Environment Record for `phrase` changes as the script runs.

Reading the stages:

  1. 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.
  2. Execution reaches let phrase. No value was assigned, so it’s now undefined. From here on you can use it.
  3. phrase gets "Hello".
  4. phrase is 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}`);
}
global (at start)phrase: <uninitialized>say: ƒ readynull
At script start: `let` variables are uninitialized, but a Function Declaration is already a usable function.

This early-readiness applies only to Function Declarations. A Function Expression assigned to a variable — let say = function(name) &#123;...&#125; — 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:

say: Lexical Envname: “Maya”globalphrase: “Hello”outernull
Two live environments during the say('Maya') call: the inner call and the global one it points to.
  • 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 phrase and the say function 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:

  • name is found right away in the inner environment.
  • phrase isn’t local, so the engine follows the outer reference and finds it in the global environment.
say envname: “Maya” ← foundglobal envphrase ← foundlook for phrase:not here →follow outer ↓
Variable lookup walks outward along the outer references until it finds a match.

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:

makeStepCounter() runsteps: 0globalstepper: ƒouterstepper (the ƒ)[[Environment]]
The returned function's hidden [[Environment]] points at the makeStepCounter run where steps = 0 lives.

So stepper.[[Environment]] holds a reference to the &#123;steps: 0&#125; 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]]:

stepper() call env(empty)makeStepCounter envsteps: 0 → 1outer (from [[Environment]])
Calling stepper(): its new env borrows its outer link from [[Environment]], not from the call site.

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:

makeStepCounter envsteps: 1
steps has been read and incremented in place; the makeStepCounter environment is still alive.

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:

interactiveTwo step counters, two private environments

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:

interactiveA factory that bakes a value into each closure

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
let g = f()
g → ƒ
env { value: 123 } alive
g = null
g → null
env { value: 123 } collected
The closure's environment lives exactly as long as something reachable references the function.

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.