Execution Model, Scope, and Closures
A precise model of execution contexts and scope chains, exactly what hoisting does for each declaration type, and closures used deliberately for encapsulation and memoization.
By the end of this lesson, you can
- Trace how a scope chain resolves a variable lookup through nested execution contexts
- Explain precisely what hoisting does for var, function, let, and const, including the temporal dead zone
- Use a closure deliberately for encapsulation, a factory, or memoization — not just recognize one
- Diagnose a classic loop-variable-capture bug and explain why per-iteration let scoping fixes it
Why it matters
Functions and Scope introduced closures at an intuitive level — “an inner function remembers its outer function’s variables.” This lesson replaces that intuition with a precise model: what an execution context actually is, exactly what hoisting does (and doesn’t do) for each kind of declaration, and closures used on purpose as a design tool rather than observed as a curiosity.
Execution contexts and the scope chain
Every function call creates a new execution context: a record of that call’s local variables, its parameters, and a reference to the lexical environment it was defined in — not the one it was called from. Looking up a variable walks outward through this chain of lexical environments, stopping at the first one that has a binding for that name:
const globalValue = 'global';
function outer() {
const outerValue = 'outer';
function inner() {
const innerValue = 'inner';
console.log(innerValue, outerValue, globalValue); // all resolve, searching outward
}
return inner;
}
const fn = outer();
fn(); // "inner outer global"
inner’s scope chain is fixed at the moment it’s defined — inside outer’s lexical environment,
inside the global one — regardless of where or when it’s eventually called. fn() is called from
the top level, long after outer() has already returned, but inner still resolves outerValue
correctly. This is precisely what a closure is: a function bundled together with a live reference to
the lexical environment it closed over.
Hoisting, precisely
“Hoisting” is often described as “declarations move to the top” — close, but imprecise enough to cause real confusion. What actually happens: every execution context sets up all its declarations before running any code line by line, but different declaration types are initialized differently at that setup stage.
| Declaration | Hoisted? | Value before its own line runs |
|---|---|---|
| var x | Yes | undefined — readable, just not yet assigned |
| function f() {} | Yes, fully | The complete function — callable before its own line |
| let / const x | Yes, but not initialized | Temporal dead zone — throws ReferenceError if accessed |
| class C {} | Yes, but not initialized | Temporal dead zone, same as let/const |
console.log(varValue); // undefined — hoisted and default-initialized
console.log(letValue); // ReferenceError — in the temporal dead zone
var varValue = 1;
let letValue = 2;
Closures as a deliberate tool
Beyond “remembering a value between calls,” closures are the standard way to create private state in JavaScript — encapsulation without a class:
function createBankAccount(initialBalance) {
let balance = initialBalance; // private — no external code can reach this directly
return {
deposit(amount) {
balance += amount;
return balance;
},
withdraw(amount) {
if (amount > balance) throw new Error('Insufficient funds.');
balance -= amount;
return balance;
},
getBalance() {
return balance;
},
};
}
const account = createBankAccount(100);
account.deposit(50);
console.log(account.getBalance()); // 150
console.log(account.balance); // undefined — genuinely inaccessible, not just a naming conventionMemoization — caching a pure function’s results, keyed by its arguments — is another closure built directly on this pattern:
function memoize(fn) {
const cache = new Map(); // held in the closure, private to this specific memoized function
return function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn(...args);
cache.set(key, result);
return result;
};
}
The classic loop-capture bug
const callbacks = [];
for (var i = 0; i < 3; i++) {
callbacks.push(() => console.log(i));
}
callbacks.forEach((cb) => cb()); // 3, 3, 3 — not 0, 1, 2!
var has function scope, not block scope — there’s exactly one i binding shared across every
iteration, so by the time the callbacks actually run (after the loop has finished), they all close
over the same final value. Switching to let fixes this, because let in a for loop creates a
fresh binding for each iteration:
const callbacks = [];
for (let i = 0; i < 3; i++) {
callbacks.push(() => console.log(i));
}
callbacks.forEach((cb) => cb()); // 0, 1, 2 — each closure has its own i
Closures and memory
A closure keeps its captured variables alive for as long as the closure itself is reachable — JavaScript’s garbage collector frees memory only for objects nothing can still reach, and a live closure counts as a reference. This is usually invisible and harmless, but an accidentally long-lived closure (attached to a global event listener that’s never removed, say) can keep large captured data alive far longer than intended — a common, hard-to-spot source of a memory leak.
Check your understanding
Or reveal the answer without checking
Answer:'function'
Function declarations are hoisted with their entire body attached, so inner is fully callable — and typeof inner is 'function' — even on the line before its own declaration.
Or reveal the answer without checking
Answer:let creates a fresh binding for each loop iteration, so each closure captures its own separate i
var has function scope: one binding shared across all iterations. let has block scope and, specifically in a for loop, creates a new binding per iteration — so each closure captures a distinct i rather than all sharing the same final one.
Practice: warm-up
Predict the output, then check:
function makeMultiplier(factor) {
return function (n) {
return n * factor;
};
}
const double = makeMultiplier(2);
const triple = makeMultiplier(3);
console.log(double(5), triple(5));
Stuck? Reveal one hint at a time.
Hint 1
Each call to makeMultiplier(factor) creates a brand new execution context, with its own separate factor binding.
Hint 2
double and triple each close over their own independent factor — 2 and 3 respectively — not a shared one.
Reveal the output
Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.
10 15double and triple are two separate closures, each over its own independent factor from its
own call to makeMultiplier.
Practice: apply it
Write createIdGenerator() that returns a function generating a new sequential ID (starting at 1)
each time it’s called — with no global variable, using closure for the private counter.
Stuck? Reveal one hint at a time.
Hint 1
The counter needs to live in createIdGenerator's own scope, not as a global — the returned function closes over it.
Hint 2
Increment the counter and return its new value each time the returned function is called.
Reveal a reference solution
Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.
function createIdGenerator() {
let nextId = 1;
return function () {
return nextId++;
};
}
const generateId = createIdGenerator();
console.log(generateId()); // 1
console.log(generateId()); // 2
console.log(generateId()); // 3Summary
- A closure is a function bundled with a live reference to the lexical environment it was defined in — fixed at definition time, resolved outward through the scope chain regardless of where or when it’s eventually called.
- Hoisting differs by declaration type:
varis hoisted and default-initialized toundefined; function declarations are hoisted fully, body included;let/const/classare hoisted but stay in the temporal dead zone — inaccessible — until their own line runs. - Closures are a deliberate encapsulation tool: private state via a factory function, or a cached result via memoization, with no class required.
var’s function scope means one shared binding across all loop iterations, the cause of the classic “every callback logs the same final value” bug;letcreates a fresh binding per iteration, fixing it.- A closure keeps its captured variables reachable, and therefore alive, for as long as the closure itself is reachable — usually harmless, but the mechanism behind a class of hard-to-spot memory leaks when a closure outlives its intended purpose.