Case 13/13

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

In the wild

Where this rule bites real code

The event handlers that all edit the last row

Symptom
You attach click handlers in a var loop over table rows. Every button edits the final row, no matter which one was clicked.
Why
All the handlers captured the same loop binding, which ended at the last index — identical mechanics to the 3, 3, 3 output.
Fix
Use let for the loop variable, or read the identity from the event itself (event.currentTarget.dataset.id) instead of capturing loop state.

Environment

Browser

CI verified

Node.js v22.22.3

Last reviewed

29 July 2026

The animation on this page is a teaching model, not a diagram of browser internals. Real engines use several task queues, optimise aggressively, and do plenty of work these panels do not show. What is guaranteed is the observable ordering: the stated output is produced by actually executing the code in CI, on the runtime named above. Browser-specific behaviour follows the HTML Standard and is not executed in a real browser by CI. How this is verified →

Related · Language Execution