In JavaScript, `for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); }` logs 3, 3, 3. Explain why, and give the smallest change that makes it log 0, 1, 2.
answer
- one shared slot, not three
- callbacks run after the loop ends
- function-scoped versus per-iteration binding
- loop exits when the condition fails
- swap var for let
basics
~20 svar creates one function-scoped i shared by the whole loop, so all three deferred callbacks read the same binding, which already holds 3 by the time the timers fire. Declaring the counter with let gives each iteration its own i, logging 0, 1, 2.
solid answer
~40 sA `var` declaration is scoped to the enclosing function, not to the loop, so the loop head creates exactly one binding for `i`. All three arrow functions close over that one binding — a closure captures the binding itself, not a snapshot of its value at creation. The `setTimeout` callbacks are deferred, so the synchronous loop runs to completion first; it stops when `i < 3` is false, meaning `i` is 3, and every callback then reads 3. The minimal fix is `for (let i = 0; ...)`: a `for` head declared with `let` gets a fresh binding per iteration, seeded with that iteration's value, so the three closures capture three different bindings and log 0, 1, 2. Copying the value into a block-scoped `const` inside the body works equally well.
code
javascript · 9 linesfor (var i = 0; i < 3; i++) {
setTimeout(() => console.log('var', i), 0);
}
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log('let', j), 0);
}
// var 3, var 3, var 3, let 0, let 1, let 2go deeper
Be able to say out loud that var gives one shared variable for the whole loop, that the timers run after the loop ends, and that switching to let fixes it. Getting the value right — 3, not 2 — matters.
Explain the mechanics: closures capture bindings rather than values, var hoists to function scope, and a let loop head is given a fresh binding per iteration. Show at least two fixes and say what each one actually copies.
Generalise past setTimeout. Show that you recognise the same failure in promise reactions, stored callbacks, and any deferred work built in a loop, and describe how you would spot it in review before it reaches production.
Frame it as a code-standard question: whether the team bans var outright, what a linter can and cannot catch, and how you weigh a mechanical codemod against reviewer attention for a bug class that is silent until code becomes asynchronous.
## The observed behaviour ```js for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); } // logs: 3, 3, 3 ``` Three callbacks are scheduled and all three print the same number — one larger than any index the body ever worked with. Two independent facts combine to produce this, and a strong answer names both. ## Fact one: var produces one binding for the whole loop A *binding* is the named storage slot a variable name refers to. `var` declarations are scoped to the nearest enclosing **function** (or to the script/global scope at top level); a block, and a `for` loop head, do not create a scope for them. The declaration is hoisted to the top of that function, so the loop head does not create a variable — it just initialises the one that already exists there. So after the loop, `i` is still visible: ```js for (var i = 0; i < 3; i++) {} console.log(i); // 3 ``` One binding means the three arrow functions are not each holding their own `i`. They all reference the same slot. ## Fact two: a closure captures the binding, not the value When a function is created inside a scope, it keeps a reference to that scope's environment. It does not copy the current values out. Reading `i` inside the callback is a lookup performed **when the callback runs**, walking the scope chain to the one slot `var i` created. Whatever that slot holds at call time is what you see. This is exactly why the puzzle is invisible in synchronous code: `for (var i = 0; i < 3; i++) console.log(i)` prints 0, 1, 2, because the read happens while the loop is still on that iteration. ## Why the value is 3 and not 2 The timer callbacks do not run during the loop. They are scheduled and run later, after the currently executing synchronous code finishes. By then the loop has terminated, and a `for` loop terminates when its condition is **false** — the last increment made `i` equal to 3, `3 < 3` failed, and the loop exited leaving 3 in the slot. So the callbacks read 3, not the last index used, which is 2. Saying "it prints the last index, 2" is the classic near-miss. ## The fix, and why it works ```js for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); } // logs: 0, 1, 2 ``` `let` in a `for` head is special-cased by the specification: the loop creates a **new environment with a fresh copy of the loop variable for every iteration**, carrying the previous iteration's value forward before the increment runs. Each callback therefore closes over a different binding, frozen at that iteration's value because nothing ever writes to it again. A copy inside the body has the same effect without touching the head, which is useful when the counter itself must stay `var` for other reasons: ```js for (var i = 0; i < 3; i++) { const shown = i; // fresh block-scoped binding each iteration setTimeout(() => console.log(shown), 0); } ``` `setTimeout` also forwards any extra arguments to the callback, so `setTimeout(console.log, 0, i)` passes the current value as an argument — argument passing copies the value, so there is nothing left to share. ## The generalisation worth stating The bug is not about timers. Any function that is *created inside a loop* and *called after the loop finishes* — an event handler, a promise reaction, a callback stored in an array — sees the final value when the loop variable is `var`. The rule to carry away: with deferred work, make sure each iteration gets its own binding, or pass the value as an argument at creation time. ```js const fns = []; for (var i = 0; i < 3; i++) fns.push(() => i); fns.map(f => f()); // [3, 3, 3] ``` The same loop with `let` yields `[0, 1, 2]`.
- Why does the same loop print 0, 1, 2 correctly if you log directly in the body instead of inside setTimeout?Because the read then happens while that iteration is still executing. The closure problem only becomes visible when the function runs after the loop has finished; a synchronous read of the shared binding sees the value it holds at that moment, which is that iteration's value.
- Why is the output 3, 3, 3 rather than 2, 2, 2?A `for` loop exits when its condition evaluates to false, which requires the increment to have already run. After the final iteration `i` becomes 3, `3 < 3` is false, and the loop stops — leaving 3, not the last index the body used, in the shared binding.
- If you keep var but wrap the loop body in a plain block with braces, does that fix it?No. Blocks do not scope `var` at all; the declaration is still hoisted to the enclosing function, so there is still exactly one binding. Only a block-scoped declaration — `let` or `const` — inside the block, or a `let` loop head, creates a new binding per iteration.
saying these in an interview costs you the question
- Says the callback copies i's value when it is created
- Claims the output is 2, 2, 2
- Blames the timer delay being too short
- Thinks var is block-scoped inside a for loop
- Says let makes setTimeout run synchronously