Fix: JavaScript Closure in Loop — All Callbacks Get the Same Value
Part of: JavaScript & TypeScript Errors
Quick Answer
How to fix the JavaScript closure loop bug where all event handlers or setTimeout callbacks return the same value — using let, IIFE, bind, or forEach instead of var in loops.
The Error
You create functions inside a loop and expect each one to capture the current loop value — but they all use the last value instead:
for (var i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i); // Prints 5, 5, 5, 5, 5 — not 0, 1, 2, 3, 4
}, 1000);
}Or with event listeners:
var buttons = document.querySelectorAll("button");
for (var i = 0; i < buttons.length; i++) {
buttons[i].addEventListener("click", function() {
console.log("Button", i); // Always logs the last index
});
}Or in Node.js:
var handlers = [];
for (var i = 0; i < 3; i++) {
handlers.push(function() { return i; });
}
console.log(handlers[0]()); // 3
console.log(handlers[1]()); // 3
console.log(handlers[2]()); // 3Why This Happens
This is one of the most classic JavaScript bugs, caused by the combination of var hoisting and closures.
var declarations are function-scoped (or global-scoped if outside a function) — there is only one i variable shared across all iterations. Each callback function closes over the same i variable, not a copy of its value at the time the loop iteration ran.
By the time any callback executes (after the loop completes), i has already been incremented to its final value:
// What you think happens:
// Iteration 0: creates function that captures i=0
// Iteration 1: creates function that captures i=1
// ...
// What actually happens:
// There is ONE variable `i` in memory
// All functions close over a reference to that same variable
// When functions run, they read i's CURRENT value: 5The loop body executes synchronously (creating all the functions), then the callbacks run later (asynchronously or on demand) — by which time i === 5.
The mental model that trips most developers is conflating the lifetime of a value with the lifetime of a binding. A closure captures the binding, not the value. With var, the binding outlives every iteration of the loop because it is hoisted to the enclosing function. Each callback you create points back to the same memory slot. When the slot is mutated by i++, every callback sees the new value the next time it reads i.
The same trap appears in less obvious shapes. Returning an array of callbacks from a factory, scheduling work with requestAnimationFrame, registering Node process.nextTick callbacks, or queueing IIFE-wrapped promises all reproduce it as long as a var-declared loop variable is in scope. If your loop body is synchronous and uses the value immediately (for example writing into an array indexed by i), you will not see the bug. The moment any side effect is deferred — including microtasks via Promise.resolve().then(...) — the bug surfaces.
Platform and Environment Differences
The closure-in-loop bug is a language-level pattern, but how it behaves and how you reproduce it varies across runtimes, toolchains, and historical browser versions:
- ES5 vs ES6 scoping.
letandconstonly exist from ES2015. Code targeting ES5 (for example legacy Internet Explorer 11 builds without polyfills) cannot use block scoping directly. Transpilers like Babel, swc, and the older Bublé rewriteletin loops to per-iteration IIFEs or unique-namedvardeclarations to preserve the binding-per-iteration semantics. If you read the compiled output, you will see synthetic names like_iand_i2— that is intentional. Misconfigured targets that produce ES5 from a TypeScript source can silently regress correctletcode into incorrectvarcode if a custom plugin strips down the loop incorrectly, so audittsc --targetand your Babel preset. - Browser engine differences. V8 (Chrome, Edge, Node), JavaScriptCore (Safari), and SpiderMonkey (Firefox) all implement per-iteration bindings for
let, but their closure capture optimizations differ. V8 may inline simple loop closures, JSC tends to allocate context objects more eagerly, and SpiderMonkey applies its own escape analysis. The observable behavior is identical, but if you debug closures in DevTools you will see different scope chain shapes between engines. - Node, Deno, and Bun. All three support
letnatively. Bun’s transpiler runs your code as written without an extra ES5 step, and Deno relies on V8 directly. The pitfall on Node is--experimental-vm-modulesor custom loaders that re-transpile your code; check the loader output if you are usingts-node,tsx, or a custom--loaderflag. - Async closures and the event loop. The host environment controls when deferred callbacks run. Browsers run microtasks (Promise reactions) after each task; Node 11+ matches that. The closure still references the same
iregardless of which queue runs it — the queue only affects ordering, not capture. - TypeScript impact. In
target: es5, the compiler downlevelsletin loops to a closure-per-iteration helper. Intarget: es2015or higher it leavesletalone and lets the runtime handle it. If you see different runtime behavior between a dev build and a production build, compare the compiled targets — that is almost always the cause.
Fix 1: Replace var with let (Recommended)
let is block-scoped. In a for loop, each iteration creates a new binding of the loop variable — each callback closes over its own distinct copy:
Broken — var:
for (var i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i); // 5, 5, 5, 5, 5
}, 1000);
}Fixed — let:
for (let i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i); // 0, 1, 2, 3, 4
}, 1000);
}This is the simplest and most readable fix. let was introduced specifically to solve this class of problems. Unless you need to support Internet Explorer without transpilation, use let for loop variables.
Why this works: With
let, JavaScript creates a new scope (a new binding ofi) for each iteration of the loop. Each closure captures its owni, which is frozen at that iteration’s value. Withvar, there is only one sharedithat all closures reference.
Fix 2: Use an IIFE to Create a New Scope (Legacy Code)
Before let was available (pre-ES6), the standard fix was an Immediately Invoked Function Expression (IIFE) to create a new scope per iteration:
for (var i = 0; i < 5; i++) {
(function(j) {
setTimeout(function() {
console.log(j); // 0, 1, 2, 3, 4 — j is a local copy
}, 1000);
})(i); // Pass i as argument — captured as j in the new scope
}The IIFE creates a new function scope for each iteration. i is passed as an argument and captured as the local parameter j — a separate variable that does not change when the loop increments i.
This pattern still appears in older codebases and transpiled output. Prefer let for new code.
Fix 3: Use forEach Instead of a for Loop
Array methods like forEach, map, and filter pass the current element and index as arguments to the callback — no closure-over-variable issue:
var items = ["a", "b", "c", "d", "e"];
// Broken — var in for loop
for (var i = 0; i < items.length; i++) {
setTimeout(function() {
console.log(items[i]); // undefined, undefined, ... (i is out of bounds)
}, 1000);
}
// Fixed — forEach passes current value directly
items.forEach(function(item, index) {
setTimeout(function() {
console.log(item, index); // "a" 0, "b" 1, "c" 2, ...
}, 1000);
});forEach calls the callback with the current value — no shared variable to close over. This is the cleanest approach when iterating over arrays.
Fix 4: Use bind to Capture the Current Value
Function.prototype.bind() creates a new function with arguments pre-filled:
function logIndex(i) {
console.log(i);
}
for (var i = 0; i < 5; i++) {
setTimeout(logIndex.bind(null, i), 1000);
// bind creates a new function with i's current value locked in
}
// Output: 0, 1, 2, 3, 4bind(null, i) creates a new function where the first argument is permanently set to the current value of i. The bound function does not close over i — it uses the captured argument.
Fix 5: Fix Event Listeners in Loops
The closure bug is especially common with event listeners:
Broken:
var buttons = document.querySelectorAll(".btn");
for (var i = 0; i < buttons.length; i++) {
buttons[i].addEventListener("click", function() {
alert("Button " + i + " clicked"); // Always shows last index
});
}Fixed with let:
var buttons = document.querySelectorAll(".btn");
for (let i = 0; i < buttons.length; i++) {
buttons[i].addEventListener("click", function() {
alert("Button " + i + " clicked"); // Correct index
});
}Fixed using data attributes (alternative approach):
var buttons = document.querySelectorAll(".btn");
buttons.forEach(function(button, index) {
button.dataset.index = index; // Store index on the element
button.addEventListener("click", function(event) {
alert("Button " + event.currentTarget.dataset.index + " clicked");
});
});Fixed using event delegation (most scalable):
document.querySelector(".button-container").addEventListener("click", function(event) {
var button = event.target.closest(".btn");
if (!button) return;
var index = Array.from(button.parentElement.children).indexOf(button);
alert("Button " + index + " clicked");
});Event delegation attaches one listener to the parent — no per-element loops, no closure issues.
Fix 6: Fix Async Patterns in Loops
When combining loops with Promises or async/await, similar issues arise:
Broken — var in async loop:
for (var i = 0; i < 3; i++) {
fetch("/api/item/" + i).then(function(response) {
console.log("Got item", i); // Always logs last i
});
}Fixed with let:
for (let i = 0; i < 3; i++) {
fetch("/api/item/" + i).then(function(response) {
console.log("Got item", i); // 0, 1, 2
});
}For sequential async operations with await:
// Wrong — does not wait for each fetch
for (let i = 0; i < 3; i++) {
await fetch("/api/item/" + i); // This works correctly — let + await in async function
}
// All at once with Promise.all:
const results = await Promise.all(
[0, 1, 2].map(i => fetch("/api/item/" + i))
);Common Mistake: Using
varinsideasyncfunctions and expecting loop variables to be scoped per iteration. The async/await syntax does not change howvarscoping works — useletorconstconsistently in all modern JavaScript.
Fix 7: Fix the Bug in React and Framework Contexts
In React, the closure bug appears when creating handlers inside render:
Broken — var in render loop (rare but still seen in class components):
render() {
var items = this.state.items;
var buttons = [];
for (var i = 0; i < items.length; i++) {
buttons.push(
<button key={i} onClick={() => this.handleClick(i)}>
{items[i].name}
</button>
);
// With var, all buttons call handleClick with the same i
}
}Fixed:
// Option 1: let
for (let i = 0; i < items.length; i++) { ... }
// Option 2: map (idiomatic React)
{items.map((item, index) => (
<button key={item.id} onClick={() => this.handleClick(index)}>
{item.name}
</button>
))}
// Option 3: bind the index
<button onClick={this.handleClick.bind(this, index)}>
// Option 4: pass as data attribute and read in handler
<button data-index={index} onClick={this.handleClick}>In modern React with function components and hooks, this problem is less common because var in loops is rarely used — but the underlying closure behavior still applies to any situation where functions are created inside loops.
Still Not Working?
Check for nested loops. If you have for (let i ...) with an inner for (var j ...), the outer i is safe but the inner j still shares a single binding. Use let for all loop variables.
Check for const in loops. const is also block-scoped like let and works the same way for preventing closure bugs. However, const i in a for loop throws an error because i++ attempts to reassign a constant. Use let for numeric counters and const for for...of loops:
for (const item of items) { // OK — new binding per iteration
setTimeout(() => console.log(item), 100);
}
for (const [index, item] of items.entries()) { // Also OK
setTimeout(() => console.log(index, item), 100);
}Audit your codebase for var in loops. ESLint’s no-var rule flags all var declarations and prefer-const encourages const where possible:
{
"rules": {
"no-var": "error",
"prefer-const": "warn"
}
}Inspect transpiled output before blaming the runtime. If your project ships through Babel, swc, or tsc with target lower than es2015, your let loops are rewritten. A misconfigured plugin that strips the rewrite helper turns valid let code back into the buggy var shape. Run the production bundle in a browser console and check whether the loop variable is actually block-scoped before you keep refactoring source.
Watch for for...in over object keys. for (var key in obj) exhibits the same single-binding behavior as the numeric loop. Switch to for (const key in obj) or Object.entries(obj).forEach(...). The same applies to for...of over iterables.
Beware of closures inside framework lifecycle methods. Vue setup functions, Svelte $: reactive statements, and Solid effects all create closures that may run later. If you assign a var counter outside the loop and read it inside an effect, you will see the trailing-value problem even without an explicit loop. The fix is the same: declare the captured variable with let or const inside the smallest scope that needs it.
Reach for the debugger when output looks wrong. Set a breakpoint inside the closure, then inspect the Scope panel in DevTools. You will see whether your callback is closing over a Block scope (good — let/const) or a Function scope (bad — var). This is faster than re-reading the code.
For related JavaScript async issues, see Fix: UnhandledPromiseRejection. For loop-related rendering bugs in React, see Fix: Each Child in a List Should Have a Unique Key. For TypeScript closure types, see Fix: TypeScript Argument Not Assignable to Parameter. For sequential async patterns in tests, see Fix: Jest Async Test Timeout.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: UnhandledPromiseRejectionWarning / UnhandledPromiseRejection
How to fix UnhandledPromiseRejectionWarning in Node.js and unhandled promise rejection errors in JavaScript caused by missing catch handlers, async/await mistakes, and event emitter errors.
Fix: Node.js Stream pipeline() Not Working — Backpressure, Error Propagation, AbortSignal, and Web Streams Interop
How to fix Node.js stream/promises pipeline errors — uncaught stream errors, backpressure ignored, AbortSignal not propagating, async iterators in pipeline, Transform stream object mode, and converting between Node and Web Streams.
Fix: Tortoise ORM Not Working — Model Registration, Async Init, and Relationship Errors
How to fix Tortoise ORM errors — Tortoise.init not called, no module imported model, fetch_related missing, aerich migration setup, FastAPI integration patterns, and ConfigurationError missing connection.
Fix: asyncpg Not Working — Connection Pool, Prepared Statements, and Transaction Errors
How to fix asyncpg errors — connection refused localhost 5432, pool exhausted timeout, prepared statement does not exist, type codec not registered, JSON automatic conversion, and transaction rollback on exception.