Prefer reading? The full explanation
How Closures Capture Variables
var vs let in a loop · Browser
Before this
How the Event Loop Runs — why the callbacks run after the loop.
After this you can explain
Why three var-loop callbacks all print 3 while let prints 0, 1, 2 — capture-by-binding, in one picture.
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log('var i:', i), 0);
}
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log('let j:', j), 0);
} Output: var i: 3 → var i: 3 → var i: 3 → let j: 0 → let j: 1 → let j: 2
Why does var print 3, 3, 3?
The output is var i: 3 three times, then let j: 0, let j: 1, let j: 2. Both loops create three closures the same way; what differs is what those closures captured.
A closure does not store the value a variable had when the closure was created. It stores a reference to the binding — the variable itself. When the callback finally runs and reads i, it asks that binding for its current value. With var, all three callbacks hold the same binding, and by the time any timer callback runs (after the loop, after the sync code), that binding holds 3 — the value that made the loop condition fail.
Why let is different in a for-loop
let is block-scoped, but a for-loop does something extra that plain blocks do not: the specification creates a fresh environment for every iteration and copies the loop variable into it (the operation is literally called CreatePerIterationEnvironment).
So the three let callbacks are structurally different from the three var callbacks: each one captured a different binding, in a different iteration environment, holding the value that iteration had. The Scopes panel in the visualisation shows the three block environments sitting side by side, each with its own j.
The timing half of the puzzle
Scoping explains the values; the event loop explains why the question is tricky at all. The callbacks are timer callbacks, so none of them can run until the synchronous script — both loops included — has finished. By then i has reached its final value. Even setTimeout(fn, 0) changes nothing: the callbacks were always going to run after the loops.
This is also why the same code with a direct call instead of a timer prints 0, 1, 2 for var too — read i while the iteration is still running and the shared binding still holds the iteration value.
Before let existed
The experiment shows the classic pre-ES6 fix: pass the loop variable into a function. A parameter is a new binding in a new function scope, and passing an argument copies the current value into it. The timer callback then captures the parameter — a binding nothing else will ever reassign.
That is the general escape hatch whenever capture-by-binding bites: introduce a new binding that holds the value you want frozen. let-in-for just does it for you, per iteration, by spec.
Reading about it is one thing. Watching it run is another.
↑ Run it step by step