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.
Why a var loop prints 3 3 3
Section titled “Why a var loop prints 3 3 3”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 3All 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()seesouter’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.
conststops the binding from being pointed at a new box, but aconstarray 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.
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.
The stale-closure trap and its fix
Section titled “The stale-closure trap and its fix”Apply the model to the var i loop. Three things are true at once:
var iis function-scoped. The whole loop shares oneibinding: one box, not three.- Each callback closes over that binding, not over the value it held at iteration time.
- By the time the timers fire, the loop has run
iup to3. All three callbacks read through their pointer and find3.
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 3One 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.
for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0);}// Output: 0 1 2
for (const item of [0, 1, 2]) { setTimeout(() => console.log(item), 0);}// Output: 0 1 2A fresh binding per iteration. let and const are block-scoped, so each iteration creates a fresh i (or item) binding, and each callback closes over a different one. for...of builds in per-iteration block scope, so its default is already the right shape.
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));}let-based for loop and the Array.from form are correct, because each callback needs its own captured binding. let gives a block-scoped per-iteration i, and Array.from’s callback parameter is the same idea: each invocation has its own i. The var version shares one i across the whole loop, so every callback reads the final value n. The for...in version iterates string-keyed enumerable properties (including inherited ones) and produces string indices, not numeric ones — the wrong reach for an array even when the printed digits happen to look right.How closures hide private state
Section titled “How closures hide private state”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(); // 1next(); // 2next(); // 3count 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.
Closing exercise: predict the output
Section titled “Closing exercise: predict the output”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 calllet name = 'first';const greet = () => console.log(name);name = 'second';greet();Scenario 1. Each call to makeCounter creates a fresh closure with its own count binding. a() increments a’s count to 1, then to 2; b() increments its separate count to 1. The line prints 1 2 1.
Scenario 2. let gives each iteration its own block scope, so each callback closes over a different i binding (0, 1, 2). They print in order, but only after all synchronous code finishes.
Scenario 3. greet doesn’t snapshot name at definition; it reads the name binding at call time. The reassignment to 'second' happens before greet() runs, so the closure reads the current value and prints second.
Why second appears before 0 1 2. setTimeout(..., 0) queues a callback to run after the current synchronous code finishes, so the synchronous greet() runs before the three queued callbacks, even though the timers were scheduled first. The closure point stands either way: what each callback reads is decided by what its captured binding holds at the moment it runs.
External resources
Section titled “External resources”MDN's canonical closure reference — scope chains, the counter factory pattern, and the loop trap covered at language depth.
The depth-pass for students who want to go past the model this lesson installs — lexical environment internals, hoisting, the temporal dead zone, and closure mechanics at spec level.
React 19.2 (October 2025) shipped useEffectEvent stable as the canonical fix for stale closures in effects — the modern replacement for the older useRef workaround.