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?
answer
- one environment per iteration
- value copied forward before the increment
- increment writes to the new copy
- previous iteration's binding never reassigned
- body assignment still steers the loop
basics
~20 sThe 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.
solid answer
~50 sFor a `for` statement whose head uses `let`, the specification creates one environment per iteration rather than one for the loop. Before each new iteration, the engine builds a fresh environment and copies the current value of the loop variable into it; the increment expression and the next condition test then evaluate in that new environment. So `i++` mutates the incoming iteration's copy, and the binding the previous iteration's closures captured is left frozen at its value. That is what makes `fns.push(() => i)` inside the loop produce 0, 1, 2 rather than 3, 3, 3. Because the value is copied forward, assigning to `i` inside the body still affects the following iteration — the bindings are separate slots, not independent counters. `const` cannot be used in a counting `for` head, since the increment would assign to a constant.
code
javascript · 7 linesfor (let i = 0; i < 5; i++) {
if (i === 1) i = 3;
console.log(i);
}
// 0
// 3
// 4go deeper
Know the practical rule: a for loop declared with let hands each iteration its own copy of the counter, which is why callbacks created inside it remember the right number.
Be ready to describe the copy-forward sequence — new environment, value copied in, then the increment — and to predict the output of a loop whose body assigns to the counter.
Use the model to reason about unfamiliar cases quickly: for...of heads, blocks in while loops, and code where a helper mutates the counter. Explain to a team why the semantics keep control flow intact while fixing capture.
Own the tradeoff the design encodes: per-iteration bindings cost an environment allocation per pass in the general case, and engines optimise it away only when nothing captures. Be able to say when that matters and when it is noise.
## The puzzle inside the fix Everyone learns that `let` fixes the loop-capture bug. The interesting question is *how*, because the head declares `i` exactly once and the loop clearly keeps counting across iterations. If each iteration had a genuinely independent variable, the counter could never advance. ## The per-iteration environment An *environment* is the runtime record holding a scope's bindings. Normally one block equals one environment. A `for` statement with a lexical head (`let`, or `const` in a `for...of`) is special-cased: the specification's loop evaluation creates a **new environment for each iteration**, and copies the values of the head's bindings from the previous iteration's environment into the new one before continuing. The ordering is the part worth memorising: 1. The head's declaration is evaluated once, creating iteration environment #0 with `i = 0`. 2. The condition is tested, the body runs in that environment. 3. A **new** environment is created and the current value of `i` is copied into it. 4. The **increment runs in the new environment**, then the condition is tested there, then the body runs there. 5. Repeat. So the write performed by `i++` lands on the incoming iteration's copy. The binding that the just-finished iteration's closures captured is never assigned again — it is effectively frozen at that iteration's value, which is precisely why deferred callbacks see 0, 1, 2: ```js const fns = []; for (let i = 0; i < 3; i++) fns.push(() => i); console.log(fns.map(f => f())); // [0, 1, 2] ``` ## They are separate slots, not separate counters Because each iteration's value is copied forward, mutating `i` in the body still steers the loop: ```js for (let i = 0; i < 5; i++) { if (i === 1) i = 3; console.log(i); } // 0, 3, 4 ``` The assignment writes to the current iteration's binding; that value is then copied into the next environment, where the increment turns 3 into 4. Separate bindings do not mean isolated iterations — they mean that once an iteration ends, nothing can retroactively change what its closures observe. ## Where the copy does and does not happen - `for (let i = 0; ...)` — per-iteration binding, copied forward as described. - `for (const x of iterable)` and `for (let x of iterable)` — a fresh binding per iteration too, initialised from the iterator rather than copied. `const` is fine here because nothing assigns to `x` after initialisation. - `for (const i = 0; i < 3; i++)` — this is not a way to be extra safe: the first `i++` throws `TypeError: Assignment to constant variable`. - `while (cond) { let x = ...; }` — the block creates a fresh `x` each pass, because entering a block creates a new environment for its lexical declarations. The special copy-forward rule is only about the *head* of a `for` statement. - `for (var i = 0; ...)` — no per-iteration environment at all; one function-scoped binding for the whole loop. ## Reasoning about it without the spec text A practical mental model: with `let`, the loop body behaves as if it were a function called once per iteration with the counter as a parameter, and the counter value is threaded from one call to the next. That model predicts both observable behaviours — closures see their own iteration's value, and assigning to the counter still influences the next iteration. ## Why the design is the way it is Making the head binding per-iteration is what allows `let` to be a drop-in replacement for `var` in existing loops: control flow is unchanged, only the identity of the captured slot differs. Had the committee made each iteration's variable independent, ordinary counting loops would have broken; had they kept one binding, `let` would not have fixed the capture bug at all. The copy-forward step is the compromise that buys both.
- Can you write `for (const i = 0; i < 3; i++)` to make the per-iteration binding immutable?No. The head binding is created once and then copied forward, but the increment expression still performs an assignment, so the first `i++` throws `TypeError: Assignment to constant variable`. `const` works in `for...of` and `for...in` heads, where each iteration's binding is initialised rather than assigned.
- Does a `while` loop give a fresh binding per iteration as well?Its body block does, for anything declared with `let` or `const` inside the block — entering a block creates a new environment each pass. But a counter declared outside the `while` is a single binding, so closures created in the body all share it. The copy-forward rule is specific to a `for` head.
- How would you show experimentally that each iteration really has a distinct binding?Push a getter and a setter closure from the same iteration into an array: `for (let i = 0; i < 2; i++) pairs.push([() => i, v => { i = v; }])`. Calling the setter from iteration 0 changes only what iteration 0's getter returns, proving the slots are distinct rather than one shared variable.
saying these in an interview costs you the question
- Says let makes the loop variable immutable
- Claims the closure stores a copy of the value
- Thinks each iteration restarts the counter independently
- Believes the increment updates the previous iteration's binding
- Assumes const works in a counting for head