Skip to main content

Що не так з var у циклі та setTimeout?

var in a loop with setTimeout - all callbacks print the final loop value, not the value from their iteration, because var is function-scoped and every iteration shares the same variable.

Theory

TL;DR

  • var i is one variable for the whole function. All three setTimeout callbacks point to it.
  • setTimeout is a macrotask. It runs after the loop finishes, when i is already 3.
  • let i creates a new binding per iteration. Each callback gets its own copy.
  • Fix: replace var with let, or use an IIFE in ES5 code.

Quick example

javascript
// Problem: var (prints 3, 3, 3) for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 100); // all callbacks close over the same i } // Fix: let (prints 0, 1, 2) for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 100); // each iteration has its own i }

var i gets hoisted to the function scope. By the time the callbacks fire, the loop is done and i is 3. With let i, the engine creates a fresh binding for each iteration, so each callback captures a different value.

Why var fails here

The loop runs synchronously. JavaScript sends each setTimeout callback to the macrotask queue, and that queue is processed only after all synchronous code finishes. So all three callbacks run when i is already 3. They all read the same variable and print the same value.

The 0ms version trips people up on interviews more than the 100ms one, because zero delay feels like "right now". But 0ms is not synchronous. The callback still waits in the queue.

let solves this because the spec defines a fresh binding of i for each iteration. Each closure captures a different binding, not the same shared variable. That is the whole fix. Understanding how the event loop works and how closures capture variables makes this click faster.

When to use what

  • Loop with setTimeout, Promises, or event listeners: use let.
  • ES5 codebase without let: IIFE with the value passed as an argument: (function(i){ setTimeout(() => console.log(i), 100); })(i).
  • forEach or .map(): safe by default, each callback argument creates its own scope automatically.

How the engine handles this

V8 hoists var i to the top of the enclosing function, creating one Lexical Environment entry for the entire loop. let i triggers a "for loop block scope" mechanism from the spec: the engine creates a new Lexical Environment per iteration and copies the current binding value into it. This is also why hoisting is worth knowing before this question comes up.

Common mistakes

Mistake 1: assuming setTimeout(..., 0) runs during the loop

javascript
for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); // still 3, 3, 3 }

0ms delay does not mean synchronous. The callback still goes to the macrotask queue and runs after the loop finishes.

Mistake 2: IIFE without passing i as an argument

javascript
// Wrong: still closes over the outer i for (var i = 0; i < 3; i++) { (function() { setTimeout(() => console.log(i), 100); // 3, 3, 3 })(); } // Correct: i becomes a local parameter for (var i = 0; i < 3; i++) { (function(i) { setTimeout(() => console.log(i), 100); // 0, 1, 2 })(i); }

The IIFE creates a new scope, but if you do not pass i as an argument, the inner function still reads the outer i. Passing it in creates a local copy at call time.

Mistake 3: trying const in a for loop

javascript
for (const i = 0; i < 3; i++) {} // SyntaxError: Assignment to constant variable

const forbids reassignment. The i++ step breaks immediately. let is the right tool here.

Follow-up questions

Q: Why does synchronous console.log(i) inside the loop print 0, 1, 2, but setTimeout does not?
A: Synchronous code runs inside the loop body, where i has the current iteration value. setTimeout defers execution to after the loop ends via the event loop, when i is already 3.

Q: What about for...of with var?
A: Same issue. for (var x of arr) reassigns x each iteration. All callbacks close over the same x and read its final value. for (let x of arr) fixes it.

Q: Two ways to fix this in ES5?
A: First, IIFE with argument: (function(i){ setTimeout(() => console.log(i), 100); })(i). Second, use .forEach(), which passes each element as a function argument and creates a new closure per call automatically.

Q: Can forEach have the same bug?
A: Yes, if you close over an external var instead of using the callback argument. arr.forEach(function(){ setTimeout(() => console.log(externalVar), 100); }) reads externalVar at callback time, not at registration time. The callback argument is always safe.

Examples

Basic: the classic problem

javascript
for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 100); } // Output: 3, 3, 3 for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 100); } // Output: 0, 1, 2

Three callbacks, one shared variable, and a queue that fires after the loop. That is the whole problem. let creates three separate bindings instead of one.

Intermediate: for...of has the same trap

javascript
// var breaks for...of too for (var item of ['a', 'b', 'c']) { setTimeout(() => console.log(item), 0); } // Output: 'c', 'c', 'c' // let fixes it for (let item of ['a', 'b', 'c']) { setTimeout(() => console.log(item), 0); } // Output: 'a', 'b', 'c'

The same scoping rule applies to for...of. var item is hoisted out of the loop body, so all callbacks see the last assigned value. let item creates a fresh binding per iteration.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?