Skip to content

01 · Closures & Scope Deep Dive

This module builds directly on lexical scope from Level 1 · Module 4 — if function scope and hoisting aren't solid yet, revisit that page first.

Lexical scope, revisited

A function's scope is determined by where it is written in the source, not by where it's called from. Every function "remembers" the scope chain that existed at the moment it was defined.

function makeGreeter() {
  const greeting = "Hello"; // lives in makeGreeter's scope

  return function greet(name) { // defined here, so it can see `greeting`
    return `${greeting}, ${name}!`;
  };
}

const greet = makeGreeter();
console.log(greet("Ada")); // Hello, Ada!

greet still works even though makeGreeter() already finished running. That's the whole idea of a closure.

What a closure actually is

A closure is the combination of a function and the lexical environment it was declared in. The inner function keeps a live reference to the outer variables — not a snapshot — so changes are visible across calls.

function makeCounter() {
  let count = 0; // private state, not accessible from outside

  return {
    increment() {
      count += 1;
      return count;
    },
    reset() {
      count = 0;
    },
  };
}

const counter = makeCounter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
counter.reset();
console.log(counter.increment()); // 1

// `count` cannot be reached directly — no counter.count property exists
console.log(counter.count); // undefined

Each call to makeCounter() creates a brand-new, independent count:

const counterA = makeCounter();
const counterB = makeCounter();

counterA.increment();
counterA.increment();
counterB.increment();

console.log(counterA.increment()); // 3
console.log(counterB.increment()); // 2 — completely separate closure

The module pattern

Before ES modules existed, closures were the standard way to create private state and a public API in the same object — this is still useful inside a single file or function today.

const bankAccount = (function () {
  let balance = 0; // private — only reachable through the returned methods

  function deposit(amount) {
    if (amount <= 0) throw new Error("deposit must be positive");
    balance += amount;
    return balance;
  }

  function withdraw(amount) {
    if (amount > balance) throw new Error("insufficient funds");
    balance -= amount;
    return balance;
  }

  function getBalance() {
    return balance;
  }

  return { deposit, withdraw, getBalance }; // public API
})(); // IIFE — Immediately Invoked Function Expression, runs right away

bankAccount.deposit(100);
bankAccount.withdraw(30);
console.log(bankAccount.getBalance()); // 70
console.log(bankAccount.balance);      // undefined — truly private

The classic loop pitfall

A common bug: capturing a loop variable declared with var inside a callback, expecting each callback to remember "its" value.

// Broken: var is function-scoped, so all three callbacks share ONE `i`
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log("var i:", i), 0);
}
// var i: 3
// var i: 3
// var i: 3

By the time the callbacks run, the loop has already finished and i is 3 for all of them, because there was only ever one i in the whole loop.

// Fixed: let creates a fresh binding for each iteration
for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log("let j:", j), 0);
}
// let j: 0
// let j: 1
// let j: 2

If you're stuck with var (e.g. maintaining older code), wrap the body in an IIFE to force a new scope per iteration:

for (var k = 0; k < 3; k++) {
  (function (capturedK) {
    setTimeout(() => console.log("captured k:", capturedK), 0);
  })(k); // pass the current value in explicitly
}
// captured k: 0
// captured k: 1
// captured k: 2

Closures over mutable state — a caution

Closures capture variables, not values. If several functions share a closure and one mutates the variable, every function sees the new value — which is often exactly what you want, but can also cause subtle bugs.

function createSharedState() {
  let value = "initial";

  return {
    read: () => value,
    write: (newValue) => {
      value = newValue;
    },
  };
}

const state = createSharedState();
console.log(state.read()); // initial
state.write("changed");
console.log(state.read()); // changed — both functions share the same `value`

Scope vs. closures cheat sheet

Concept What it means
Lexical scope Where a variable is accessible, decided by source-code position
Block scope let/const are confined to the nearest { }
Function scope var is confined to the nearest function, ignoring blocks
Closure A function plus the scope it was created in, kept alive after the outer function returns
Module pattern Using a closure (often an IIFE) to expose a public API while hiding private state

How It Actually Works

A closure isn't "a function that remembers stuff" in some magical sense — it's a function value paired with a live reference to the lexical environment record it was created in. When V8 compiles a function, its parser statically determines which outer variables the function body references; if any inner function might outlive the outer call (by being returned, stored, or passed as a callback), V8 allocates that variable's storage on the heap instead of the normal stack frame, specifically so it survives after the outer function returns and its stack frame is popped. Variables the optimizer proves are never captured stay on the stack and get reclaimed immediately — closures aren't free, but they only cost what they actually capture.

This has a sharp consequence: a closure keeps its entire environment record alive, not just the one variable it uses. If a function captures one small variable but is defined in a scope that also holds a huge array, that array can stay reachable (and un-garbage-collected) for as long as the closure exists, unless V8's optimizer can prove the closure never touches it — a documented source of surprising memory retention in long-lived closures like event handlers or memoization caches. The classic for (var i...) loop bug exists precisely because var creates one shared binding for the whole function, so every closure captures the same environment slot; let creates a fresh lexical environment per iteration, so each closure captures its own i.

Exercise

Write a function createQueue() that uses a closure to keep a private array. Return an object with enqueue(item) (adds to the end), dequeue() (removes and returns the front item), and size() (returns the current length) — with no way for outside code to access the underlying array directly.