Skip to main content

The var and closures pitfall in a loop

The classic trap: in a loop declared with var, every callback sees the same i, because var is function scoped. By the time those callbacks run, the loop has already finished and i holds its final value, so instead of the expected 0, 1, 2 the console prints 3 three times.

Theory

TL;DR

  • var is function scoped: the entire loop shares exactly one variable.
  • A closure captures the variable, not the value it had when the function was created.
  • An asynchronous callback reads that variable after the loop is over, when it holds the final value.
  • let in the for header creates a fresh binding per iteration and the problem disappears.
  • If var has to stay, freeze the value with an IIFE, a factory, forEach, or a setTimeout argument.

Quick example

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

Why it happens

The var i declaration is hoisted to the top of the function (or to the global scope), so there is a single variable shared by all three iterations. Each callback passed to setTimeout closes over a reference to that one variable instead of copying its value.

Then the event loop kicks in: even with a 0 delay the callback goes into the macrotask queue and runs only after the synchronous code, that is, the whole loop, has completed. At that moment i < 3 is already false because i === 3. All three callbacks read the same slot and print 3.

Fix 1: let or const

In ES6 a for (let i = ...) header gets a separate lexical binding on every iteration: the engine copies the current value into a new variable before the next step. Each callback then closes over its own variable:

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

This is the simplest and safest route, and it is exactly why modern code does not use var in loops. For a for...of loop whose element is never reassigned, const works just as well.

Fix 2: freeze the value by hand

If you really do need var, the current iteration's value has to be moved into its own variable.

An IIFE (immediately invoked function expression) creates a new scope and freezes the current i:

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

A function factory does the same thing more readably: the parameter x lives in the private environment of each created function:

javascript
function makeLogger(x) { return () => console.log(x); } for (var i = 0; i < 3; i++) { setTimeout(makeLogger(i), 0); }

Array methods (forEach, map) remove the question entirely: the callback parameter is already a separate binding per element:

javascript
[0, 1, 2].forEach(i => setTimeout(() => console.log(i), 0));

The third argument of setTimeout passes the value straight into the callback (supported in browsers and in Node.js):

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

Short checklist

  • By default use let or const in loops, it is the simplest and safest path.
  • If var is unavoidable, freeze the value with an IIFE, a factory, or an explicit argument.
  • Remember: closures capture the variable, not its instantaneous value, and with var there is only one variable for the whole loop.

Common mistakes

  • Blaming setTimeout. The timer is irrelevant: the same thing happens with any deferred call, event handler or promise callback.
  • Reading a 0 delay as "immediately". The callback still goes to the queue and runs after the synchronous code.
  • Replacing var with let only inside the loop body. The per-iteration binding comes from let in the for header, not from some variable declared deeper in the block (though a copy inside the block works too).
  • Using var in for...in and for...of with callbacks. The trap is identical, even though the values look "fresh".
  • Confusing this with this. The problem here is purely variable scope; the call site context has nothing to do with it.

Short Answer

Interview ready
Premium

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