You write a loop that should print 0, 1, and 2, one second apart. Instead you get three 3s.

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

Nothing crashed. No error. The values just aren’t what the loop clearly seems to promise. The first instinct is usually to suspect setTimeout itself — some kind of timing quirk, a race condition, maybe the callbacks are firing out of order. None of that is what’s happening.

What’s actually happening

var is function-scoped, not block-scoped. That means every single pass through the loop isn’t creating a new i — there’s exactly one i for the entire loop, shared by all three setTimeout callbacks. By the time any of those callbacks actually runs (a full second later, long after the loop has already finished all three iterations), i has already reached its final value: 3. All three callbacks look up the same variable, at the same moment, after the loop is long done — so they all see the same thing.

This isn’t a bug in setTimeout, and it isn’t a race condition. The loop runs to completion essentially instantly; the callbacks are what’s delayed, and they’re all reading from a single shared box that’s already empty of the values you wanted by the time they check it.

The fix, step by step

  1. Recognize the signature: a loop paired with a callback or setTimeout, where every output is identical — and it’s always the final value the loop variable reached, never the first or the middle ones.
  2. Change var to let:
    for (let i = 0; i < 3; i++) {
      setTimeout(() => console.log(i), 1000);
    }
    // 0
    // 1
    // 2
  3. Understand why that one-word change works: unlike var, let is block-scoped — it creates a new i, freshly bound, for every single iteration of the loop. Each setTimeout callback closes over its own separate i, not one shared variable, so each one remembers the value it was handed at that specific point in the loop.
  4. Confirm the fix conceptually, not just empirically — if you’re not sure why swapping the keyword fixed it, you’ll hit the same shape of bug again the next time it shows up somewhere let isn’t the obvious first move (a closure inside a function, for example, not just a loop).

Two mistakes worth knowing about ahead of time

Assuming it’s a setTimeout or async timing problem. This bug shows up identically with any deferred callback — event listeners, promises, array method callbacks assigned to run later — not just setTimeout. If you find yourself trying to “fix” it by reordering code or adding delays, that’s a sign you’re debugging the wrong layer. The loop already finished; nothing about timing changes what value is left behind.

Reaching for the old IIFE trick out of habit, without knowing why let alone is now enough. Before let existed, the standard fix was wrapping the loop body in an immediately-invoked function expression to force a new scope by hand. That still works, but it’s solving a problem let already solves natively — if you’re writing new code in 2026 and still reaching for an IIFE here, it’s worth understanding that let’s per-iteration binding was specifically designed to make that pattern unnecessary.

A debugging habit that works

Whenever a loop-plus-callback combination prints the same value repeatedly, don’t start by investigating the callback’s logic — check the loop variable’s declaration first. var shared across every iteration versus let scoped fresh to each one explains this entire category of bug, and recognizing that signature immediately turns a confusing multi-minute debugging session into a one-word fix.