Skip to content
Chapter 2Lesson 7

Closures: lexical capture by reference

The JavaScript closure model that predicts what a function reads when it runs, and the bugs that follow when call time arrives later than you expect.

A junior writes a loop that schedules three timeouts: for (var i = 0; i < 3; i++) setTimeout(() => console.log(i), 0). They predict 0 1 2. The actual output is 3 3 3, and the fix isn’t in setTimeout.

Every function you’ve written since the chapter on the JavaScript value model is already a closure, so there’s no new syntax here. The lesson builds one rule: what a closure sees when it runs isn’t always what was there when it was defined. The same pattern resurfaces later in React effects, Server Actions, and route-handler factories.

Predict the output of this snippet before you run it.

for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// Predicted: 0 1 2
// Actual: 3 3 3

All three callbacks run after the loop finishes, and they all read the same i. By then the loop has pushed i up to 3. Each callback prints the value i holds when it runs, not the value it had when the callback was scheduled.

A closure is the technical name for this. The callback inside setTimeout is a closure over the outer i, so “what does it print?” really means “what does the closure see when it runs?” The next section answers that.

Lexical capture, by reference, of the whole environment

Section titled “Lexical capture, by reference, of the whole environment”

A closure is a function bundled with the lexical environment where it was defined: the bindings that were in scope at that spot in the source. When the function runs, it reads those bindings themselves, not snapshots of the values they held.

The model has three parts.

  • Lexical: write-time, not call-time. What a closure can see is fixed by where the function was written, not where it’s called from. A function defined inside outer() sees outer’s locals from wherever it’s later invoked: another module, a timeout, a network response. The scope chain is decided at write time, so however far away the call site is, the closure still reads from the scopes it was written inside.

  • By reference, not by value. The closure holds a reference to the binding, not a copy of the value it held when defined. In the binding-and-box terms from the JavaScript value model, it holds the box, not the value sitting in it. When the outer scope changes the box’s contents, the closure sees the new contents the next time it runs. const stops the binding from being pointed at a new box, but a const array can still be mutated in place, and the closure sees that mutation.

  • The whole environment, not just what’s named. A closure captures every binding in its enclosing scopes, including ones the body never visibly uses. That’s why a closure whose enclosing scope holds a large object keeps that object alive in memory until the closure itself is dropped. For now, hold onto the rule: a closure carries the whole environment, not just the names you typed.

The diagram below shows the “by reference” part in one figure.

Outer scope count 0 Inner function (closure) () => console.log(count) captures by reference
Define. The closure stores a pointer to the count binding in the outer scope, not a copy of the value 0.
Outer scope count 5 Inner function (closure) () => console.log(count)
The outer scope reassigns count to 5. The closure's pointer doesn't move; it still aims at the same binding. No snapshot was taken.
Outer scope count 5 Inner function (closure) () => console.log(count) call logs: 5
Call time, not write time. The closure follows its pointer and reads whatever the binding holds now: 5.

One sentence to carry around: a closure holds a pointer to the binding, the body runs at call time, and call time can be much later than you think.

Apply the model to the var i loop. Three things are true at once:

  1. var i is function-scoped. The whole loop shares one i binding: one box, not three.
  2. Each callback closes over that binding, not over the value it held at iteration time.
  3. By the time the timers fire, the loop has run i up to 3. All three callbacks read through their pointer and find 3.

The fix changes which scope holds the binding. let and const are block-scoped, so each iteration creates a fresh i binding, a fresh box, and each callback closes over a different one. for...of gives you this for free.

for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// Output: 3 3 3

One binding for the whole loop. var is function-scoped, so all three callbacks point at the single shared i. By the time they fire the loop has run it up to 3, so they all read 3.

In a 2026 codebase you’ll essentially never write var, and for...of, .map, and .forEach callbacks all give per-iteration block scope, so the stale-closure-in-loop bug is effectively extinct: the language already fixed it. The model still matters because the same shape surfaces in three production sites where no loop fix can help.

You’re writing makeCounters(n), which builds an array of n functions. Each one should console.log its own index when called — so makeCounters(3) returns three functions that print 0, 1, 2 respectively. Which implementations behave correctly and are idiomatic in a 2026 codebase? Select all that apply.

const counters = [];
for (var i = 0; i < n; i++) {
counters.push(() => console.log(i));
}
const counters = [];
for (let i = 0; i < n; i++) {
counters.push(() => console.log(i));
}
const counters = Array.from(
{ length: n },
(_, i) => () => console.log(i),
);
const counters = [];
for (const i in Array(n).fill(null)) {
counters.push(() => console.log(i));
}

So far closures have only caused bugs, but they are also the language’s main way to hide state behind a function boundary. This code uses a closure, not a class, to do it: private state lives in a function’s scope, and the returned function is a closure over that state. The classic illustration is a counter factory:

const makeCounter = () => {
let count = 0;
return () => ++count;
};
const next = makeCounter();
next(); // 1
next(); // 2
next(); // 3

count lives inside makeCounter’s scope. Nothing outside can read it, write it, or even see that it exists; the returned function is the only surface, and it closes over count. This is the same mechanism behind useState, memoization caches, and any wrapper that holds config.

The program below packs three scenarios into one. Predict every line, then check it against the model you’ve built.

Predict every line this program prints, in order. The scenarios build from easier to harder — if you get the first two right, lean on the model for the third. Predict what this program prints, then press Check.

// 1. Counter factory — does each counter have its own count?
const makeCounter = () => {
let count = 0;
return () => ++count;
};
const a = makeCounter();
const b = makeCounter();
console.log(a(), a(), b());
// 2. The stale-closure-in-loop, now with `let`
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// 3. Outer-binding reassignment between definition and call
let name = 'first';
const greet = () => console.log(name);
name = 'second';
greet();