Skip to content

Fix: JavaScript Closure in Loop — All Callbacks Get the Same Value

FixDevs ·

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]()); // 3

Why 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: 5

The loop body executes synchronously (creating all the functions), then the callbacks run later (asynchronously or on demand) — by which time i === 5.

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 of i) for each iteration of the loop. Each closure captures its own i, which is frozen at that iteration’s value. With var, there is only one shared i that 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, 4

bind(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 var inside async functions and expecting loop variables to be scoped per iteration. The async/await syntax does not change how var scoping works — use let or const consistently 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"
  }
}

For related JavaScript async issues, see Fix: UnhandledPromiseRejection.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles