IntermediateExecution model, scope, and closures~40 min

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.

What hoisting actually initializes each declaration to, before line-by-line execution begins
DeclarationHoisted?Value before its own line runs
var xYesundefined — readable, just not yet assigned
function f() {}Yes, fullyThe complete function — callable before its own line
let / const xYes, but not initializedTemporal dead zone — throws ReferenceError if accessed
class C {}Yes, but not initializedTemporal 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:

JavaScriptA factory using closure for genuinely private state
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 convention

Memoization — 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

What does this log? function outer() { console.log(typeof inner); function inner() {} } outer();
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.

Why does switching a for loop's var to let fix the classic 'all callbacks log the same final value' bug?
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.

  1. Hint 1

    Each call to makeMultiplier(factor) creates a brand new execution context, with its own separate factor binding.

  2. 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 15

double 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.

  1. Hint 1

    The counter needs to live in createIdGenerator's own scope, not as a global — the returned function closes over it.

  2. 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()); // 3

Summary

  • 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: var is hoisted and default-initialized to undefined; function declarations are hoisted fully, body included; let/const/class are 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; let creates 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.