skip to content

Loop Variable Capture

The classic puzzle where a loop of setTimeout callbacks all print the final index with var but count correctly with let. Expect to explain the per-iteration binding and the pre-ES6 IIFE workaround.

part ofJavaScriptoverview, primer and where to startread it →
on this pageshow

questions

5

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.

level: juniorimportance: must knowfreq 85%

answer

  1. one shared slot, not three
  2. callbacks run after the loop ends
  3. function-scoped versus per-iteration binding
  4. loop exits when the condition fails
  5. swap var for let

basics

~20 s

var 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 s

A `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 lines
javascript
for (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 2

go deeper

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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

context

open as a page

A `for (let i = 0; i < 3; i++)` loop declares the variable only once in its head, yet each iteration behaves as if it has its own `i`. How does JavaScript produce that, and which iteration's binding does the `i++` update?

level: middleimportance: must knowfreq 55%

basics

~20 s

The loop creates a new environment for every iteration and copies the loop variable's current value into it. The increment then runs against the new iteration's copy, so the previous iteration's binding is never touched again and closures over it keep that iteration's value.

open as a page

`for (var v = 0; v < 3; v++) setTimeout(() => console.log(v))` logs 3, 3, 3, but `[0, 1, 2].forEach(v => setTimeout(() => console.log(v)))` logs 0, 1, 2 — and neither uses `let`. What accounts for the difference?

level: middleimportance: should knowfreq 40%

basics

~20 s

forEach calls its callback once per element, and every call creates a new environment holding its own parameter binding for that element. The counting loop has a single shared var binding instead. Callback-based iteration gets per-iteration bindings for free, whatever declaration keyword you use.

open as a page

Before `let` existed, how did you make `for (var i = 0; i < 3; i++) setTimeout(function () { console.log(i); }, 0);` log 0, 1, 2 without changing `var`? Show the wrapper and say what actually makes it work.

level: middleimportance: should knowfreq 42%

basics

~20 s

Wrap the body in an immediately-invoked function that takes the counter as a parameter. Argument passing copies the value into a fresh parameter binding per call, so the scheduled callback closes over that copy rather than the one shared var. Function.prototype.bind achieves the same by fixing the argument.

open as a page

A reviewer finds `for (var i = 0; i < ids.length; i++) { load(ids[i]).then(r => { results[i] = r; }); }` and every response ends up at one index instead of spread across the array, leaving holes. Diagnose it, and say what you would change so this class of bug cannot recur.

level: seniorimportance: should knowfreq 38%

basics

~20 s

All the promise callbacks share the single var binding for i, and they run only after the loop has finished, when i equals ids.length. So every write lands on that one out-of-range index. Declare the counter with let, or build the array from returned values instead of index writes.

open as a page