What this usually means
The loop creates closures (functions) that capture the variable by reference, not by value. By the time the callback executes, the loop has already finished and the variable holds its final value. In JavaScript, `var` has function scope, not block scope, so each closure shares the same binding. Even with `let` (which has block scope and creates a new binding per iteration), if the variable is mutated inside the loop or the closure is asynchronous, you can still see stale values if you're not careful about the capture timing. The root cause is misunderstanding that closures capture variables, not values, and that the capture happens at function creation time, but the variable is read at execution time.
The first ten minutes — establish facts before touching code.
- 1Check if the loop variable is declared with `var` or `let`. Run `node -e "for(var i=0;i<3;i++){setTimeout(()=>console.log(i),0)}"` — you'll see 3,3,3. Repeat with `let` and see 0,1,2.
- 2Look for any function (arrow or regular) defined inside a loop that references the loop variable and is stored for later execution (e.g., in an array, passed as callback).
- 3Add a `console.log(i)` immediately inside the closure to verify when it's actually evaluated (will show final value on execution).
- 4If using async/await inside a loop, check if the loop variable is used after an `await` — the value may have changed by the time the promise resolves.
- 5For React hooks in a loop (which is itself a lint error), check if the same effect or callback is re-created with stale state from a previous render.
The specific files, logs, configs, and dashboards that usually own this bug.
- searchAny `for` loop containing `setTimeout`, `setInterval`, `Promise`, `fetch`, or event listener bindings.
- searchFiles with `.forEach` or `.map` that use `var` inside the callback? Actually, `.forEach` creates a new function scope per iteration, so it's usually safe. But check if the callback itself contains a closure that captures an outer variable.
- searchReact components using `useEffect` or `useCallback` inside a loop (antipattern) — check the dependency array.
- searchNode.js code with `async` callback array (e.g., `const fns = []; for (var i=0;i<3;i++) { fns.push(() => console.log(i)); }`).
- searchCode using `Function.prototype.bind` or currying inside loops — the bound arguments are captured at bind time, but the closure still captures the variable.
Practical causes, not theory. These are the things you will actually find.
- warningUsing `var` in a loop instead of `let` — `var` hoists and creates a single binding for the entire function scope.
- warningAssuming closures capture the current value, not the variable reference — they capture the variable itself.
- warningCreating callbacks inside loops that are not immediately invoked (e.g., pushed to an array, passed to event listeners, used in promises).
- warningMutating the loop variable after the closure is created but before it executes — e.g., modifying `i` inside the loop body after defining the closure.
- warningIn async loops, using the loop variable after an `await` — the loop may have advanced the variable to the next iteration before the awaited promise resolves.
Concrete fix directions. Pick the one that matches your root cause.
- buildReplace `var` with `let` for the loop variable — `let` creates a new binding per iteration due to block scoping.
- buildWrap the closure body in an IIFE (Immediately Invoked Function Expression) that captures the current value as a parameter: `for(var i=0;i<3;i++){ (function(j){ setTimeout(()=>console.log(j),0); })(i); }`.
- buildUse `Array.prototype.forEach` or `for...of` — these provide a new scope per iteration (forEach callback is a new function invocation).
- buildUse `Function.prototype.bind` to bind the current value: `setTimeout(console.log.bind(console,i),0)` — but note it binds the value at call time, not definition time.
- buildStore the loop variable in a local `const` inside the loop block: `for(let i=0;i<3;i++){ const j = i; setTimeout(()=>console.log(j),0); }` — though with `let` this is redundant.
A fix you cannot prove is a guess. Close the loop.
- verifiedRun the code and confirm each callback logs a different value (e.g., 0,1,2).
- verifiedUse `console.trace()` inside the callback to see the call stack and verify the closure's scope chain.
- verifiedAdd debugger statements and inspect the closure's `[[Scopes]]` property in Chrome DevTools — check if the variable is in the Closure scope with the expected value.
- verifiedWrite a unit test that asserts different callbacks return different values.
- verifiedFor async code, use `Promise.all` and compare results — each promise should resolve to its own iteration value.
Things that make this bug worse or harder to find.
- warningRelying on `var` hoisting — never use `var` in loops if you need per-iteration state in callbacks.
- warningUsing `let` but still mutating the variable after the closure creation — e.g., `for(let i=0;i<3;i++){ setTimeout(()=>console.log(i),0); i++; }` — this skips values and may cause off-by-one.
- warningAssuming `Array.prototype.forEach` is always safe — it is, but if the callback itself creates a closure that captures an outer variable, the same problem can occur.
- warningUsing `async/await` inside a `for` loop and assuming the variable is frozen at the time of the `await` — the loop continues during the await, so the variable can change.
- warningIgnoring linter warnings about functions inside loops — ESLint's `no-loop-func` rule catches this pattern.
Chat App Buttons All Send the Last Message
Timeline
- 10:15Deploy new chat UI with a list of message edit buttons.
- 10:45User reports that clicking any edit button opens the last message's editor.
- 11:00Reproduce locally: all buttons on a 5-message chat open editor for message index 4.
- 11:10Check component code: map over messages, render button with onClick handler.
- 11:15Notice onClick uses `var i` from a loop in a helper function.
- 11:20Change `var` to `let` in the loop; rebuild and test.
- 11:25Buttons now open correct editor. Deploy fix.
- 11:30Write regression test to assert each button has unique handler.
I was building a chat interface where each message has an edit button. The component rendered a list of messages using a map, but behind the scenes, I had a helper function that looped over an array of message IDs to attach event listeners (old-style, before React's synthetic events). I used a `for` loop with `var i` to bind each button's click handler. The handler was supposed to call `editMessage(messages[i].id)`. But every button ended up editing the last message.
I added console.log inside the handler and saw `i` was always the final value (4 for a 5-message list). That's when I remembered the classic closure-in-loop bug. The `var i` was hoisted to the function scope, and all closures shared the same `i`. By the time any click fired, the loop had finished and `i` was 4.
I changed `var` to `let` — `let` creates a new binding per iteration — and the bug vanished. I also added a lint rule (`no-loop-func`) to catch this in the future. The fix took 10 minutes, but the debugging took an hour because I didn't immediately suspect the closure capture.
Root cause
Using `var` in a for loop to create closures that reference the loop variable — all closures capture the same variable, which ends up at its final value.
The fix
Replace `var` with `let` for the loop variable, or use an IIFE to capture the current value.
The lesson
Always use `let` (or `const` if possible) for loop variables when creating closures. Lint rules like `no-loop-func` can prevent this. Also, prefer higher-order functions like `Array.prototype.forEach` which provide a new scope per iteration.
A closure captures the variable itself, not its value at the time of closure creation. When you write `for(var i=0;i<3;i++){ fns.push(function(){ console.log(i); }); }`, each function in the array refers to the same `i` variable. By the time you call any function, the loop has completed and `i` is 3. This is the core misunderstanding: developers assume the closure captures a snapshot, but it captures a live reference.
With `let`, the JavaScript engine creates a new binding for each iteration (due to block scoping). Each closure captures a different `i` binding, each with its own value. This is specified in ECMAScript 2015 (ES6) and is why `let` solves the problem without an IIFE.
Before ES6, the standard fix was an IIFE: `for(var i=0;i<3;i++){ (function(j){ fns.push(function(){ console.log(j); }); })(i); }`. Here, `j` is a parameter of the IIFE, and each invocation creates a new scope where `j` holds the current value of `i` (passed by value). The closure captures `j`, which never changes.
The IIFE effectively simulates block scoping. It's still useful when you need to capture a value that changes within the loop body (e.g., if you mutate the loop variable after the closure creation).
Even with `let`, using `async/await` inside a `for` loop can cause stale values if you're not careful. Example: `for(let i=0;i<3;i++){ setTimeout(()=>console.log(i),0); }` works fine. But consider: `for(let i=0;i<3;i++){ await somePromise(); console.log(i); }` — here, `i` is captured correctly per iteration because the loop body is a block scope. However, if the `await` is inside a callback that is created in a loop but executed later, the same closure issue applies.
The real trap is using `Array.prototype.map` with an async callback: `const promises = arr.map(async (item) => { await something(); return item; });` — here, `item` is a parameter, so it's captured by value correctly. But if you reference an outer loop variable, you're back to square one.
Chrome DevTools allows you to inspect the closure scope of a function. Set a breakpoint inside the callback (e.g., in the console or Sources tab). In the Scope panel, look for 'Closure' scope. You'll see the captured variables listed with their current values. If you see a single variable shared across multiple closures, that's your bug.
You can also use `console.dir(fn)` to see the function's properties, including `[[Scopes]]`. But the DevTools Scope panel is more intuitive. Another trick: add `debugger;` inside the loop and inside the callback, then step through to see which `i` is captured.
Frequently asked questions
Does this bug occur with `while` or `do...while` loops?
Yes, any loop that uses `var` for the loop variable will have the same issue. The fix is the same: use `let` or an IIFE. However, `while` loops don't have an iteration variable declared in the loop header, so you typically declare it outside. That variable is in the enclosing function scope, so all closures capture the same variable. Use `let` in the outer scope, and if you need per-iteration capture, declare a new variable inside the loop body.
Can `const` be used in a for loop?
`const` cannot be used in a traditional `for` loop because the loop variable is reassigned each iteration. However, `for...of` loops can use `const` for the iteration variable: `for(const item of arr) { ... }`. This creates a new binding per iteration (like `let`), so it's safe for closures.
Does this happen with `Array.prototype.forEach`?
No, because the callback passed to `forEach` is a new function invocation per element, and the loop variable (the element) is passed as an argument, which is captured by value. However, if the callback itself creates a closure that captures an outer variable (e.g., `var i` declared outside), that outer variable can still cause stale values.
How do I fix this in React with hooks?
In React, you should not use loops to create hooks (it's a lint error). If you need per-item state, use `useState` inside a component that is mapped. For callbacks, use `useCallback` with proper dependencies. The stale closure issue in React often appears when using `useEffect` with an empty dependency array but referencing state that changes — the effect captures the initial state. The fix is to include the state in the dependency array or use a ref.
What is the difference between `var` and `let` in terms of scope?
`var` has function scope (or global scope if declared outside any function). It is hoisted to the top of its function. `let` has block scope, meaning it is confined to the nearest pair of curly braces `{}`. In a `for` loop, `let` creates a new binding for each iteration, which is why closures work correctly with `let`.