Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 007 / 100

Closures in loops: let vs var in JavaScript & TypeScript

The bug that taught a generation about scope.

Expona Admin9 min read

Closures in loops: let vs var

If you've been writing JavaScript for more than a few months, you've probably hit this bug: you set up callbacks in a loop, expect each one to remember its iteration number, and instead they all return the final value. That's not a coincidence or a quirk—it's a fundamental consequence of how closures in loops interact with variable scope. Understanding the difference between let and var isn't just about passing interviews; it's about shipping code that actually works the way you expect.

Over 5+ years building production platforms, I've seen this exact pattern cause silent bugs in event handlers, API callbacks, and async operations. Once you understand why this happens, you'll recognize it instantly and know exactly how to fix it. Let's walk through the mechanics, then look at real code.

var is function-scoped: one shared variable

Here's the thing about var: it's hoisted to the top of its enclosing function, not the block. That means a var declared inside a loop is actually the same variable for every iteration.

Let me show you what the JavaScript engine sees:

typescript

// What you write
function setupButtons() {
  for (var i = 0; i < 3; i++) {
    const button = document.createElement('button');
    button.textContent = `Button ${i}`;
    button.addEventListener('click', function() {
      console.log(`Clicked button ${i}`);
    });
    document.body.appendChild(button);
  }
}

// What JavaScript interprets (hoisting)
function setupButtons() {
  var i; // declared here, once
  for (i = 0; i < 3; i++) {
    const button = document.createElement('button');
    button.textContent = `Button ${i}`;
    button.addEventListener('click', function() {
      console.log(`Clicked button ${i}`); // closure over *the same* i
    });
    document.body.appendChild(button);
  }
}

See the comment? All three event listeners close over the same variable i. When you click any button, i is already 3 (the loop condition failed). So every click logs "Clicked button 3".

This happens because the callback function captures i by reference, not by value. The variable is shared across all iterations. When the loop finishes, i is 3, and that's what every closure sees.

let is block-scoped: a new binding per iteration

With let, the language creates a new binding for each loop iteration. That means each callback closes over its own, separate i.

typescript

function setupButtons() {
  for (let i = 0; i < 3; i++) {
    const button = document.createElement('button');
    button.textContent = `Button ${i}`;
    button.addEventListener('click', function() {
      console.log(`Clicked button ${i}`);
    });
    document.body.appendChild(button);
  }
}

// Iteration 0: i is 0, callback closes over that binding
// Iteration 1: i is 1 (new binding), callback closes over that binding
// Iteration 2: i is 2 (new binding), callback closes over that binding

Now when you click button 0, it logs "Clicked button 0". Button 1 logs "Clicked button 1". Each callback remembers its own value because let creates a fresh binding on each iteration.

This is a language-level guarantee in ES6+. You don't need workarounds; you just use let and the scoping rules handle it for you. The closure captures the right value automatically.

Callbacks with var all see the final value

This is where the bug bites hardest in real code. I've debugged this in production callback chains, async handlers, and timer setups.

Here's a common scenario from a real e-commerce platform:

typescript

// BAD: using var in a loop with async callbacks
function attachOrderTrackingHandlers(orders) {
  for (var i = 0; i < orders.length; i++) {
    const orderId = orders[i].id;
    setTimeout(function() {
      console.log(`Fetching status for order ${orderId} at index ${i}`);
      // i is always orders.length when this runs
    }, 100 * i);
  }
}

// All timeouts see the final value of i

If you have 5 orders, all setTimeout callbacks will have i = 5 when they execute. The variable is shared, and they all see its final state.

Here's the fix:

typescript

// GOOD: using let in a loop with async callbacks
function attachOrderTrackingHandlers(orders) {
  for (let i = 0; i < orders.length; i++) {
    const orderId = orders[i].id;
    setTimeout(function() {
      console.log(`Fetching status for order ${orderId} at index ${i}`);
      // Each callback has its own i
    }, 100 * i);
  }
}

// Each callback captures the correct value of i for its iteration

With let, each setTimeout callback captures the correct index. The closure now works the way you expect it to.

The old workaround: IIFE

Before ES6 standardized let, developers had to work around function scope using an immediately-invoked function expression (IIFE) to create a new scope:

typescript

// Pre-ES6 workaround (still valid, but outdated)
for (var i = 0; i < 3; i++) {
  (function(index) {
    const button = document.createElement('button');
    button.addEventListener('click', function() {
      console.log(`Clicked button ${index}`);
    });
  })(i); // pass i as argument
}

The IIFE creates a new function scope and captures i as a parameter. Each iteration gets a separate function scope with its own index parameter value. This works, but it's verbose and no longer necessary.

Don't use this pattern in new code. Use let instead. It's cleaner, more readable, and the intent is explicit.

Why this matters in production

I've seen this bug slip through code review because the symptom appears late. A click handler, an API callback, or an event listener fires and suddenly the wrong value is captured. If your tests don't trigger the callback during the loop, the bug doesn't surface until production.

Real examples I've encountered:

  • Form field validation: Loop over fields with var, attach validation handlers—all handlers trigger the last field's validator.
  • WebSocket reconnect logic: Loop over connection IDs, attach handlers—all handlers try to reconnect the last ID.
  • Ride-hailing pickup timers: Loop over ride IDs, set timers—all timers cancel the last ride instead of their own.

These aren't theoretical. They're bugs that got past testing and cost debugging hours. Using let from the start eliminates the whole class of bugs.

Use let and const, always

Here's the rule: never use var in modern JavaScript. Use let for variables that need to change, and const for everything else. This gives you block scope automatically and prevents the closure-in-loops bug entirely.

typescript

// Modern JavaScript: let and const
function setupUI(items) {
  for (const item of items) { // const when you don't reassign
    const handler = () => {
      console.log(`Handling ${item.id}`);
    };
    attachListener(handler);
  }

  for (let i = 0; i < items.length; i++) { // let only when you need to reassign
    items[i].index = i;
  }

  // var is never needed
}

When you use let and const, the closure-in-loops bug simply doesn't exist. Each iteration gets its own binding, and callbacks capture the right value. It's that straightforward.

Linters like ESLint will warn you if you use var. Follow that warning. Your future self debugging a production callback bug will thank you.

Common mistakes

Mistake 1: Mixing var with async code

typescript

// DON'T do this
for (var i = 0; i < 5; i++) {
  fetch(`/api/data/${i}`).then(response => {
    console.log(i); // i is always 5
  });
}

The fetch completes after the loop finishes, so all promise handlers see the final i. Use let:

typescript

// DO this
for (let i = 0; i < 5; i++) {
  fetch(`/api/data/${i}`).then(response => {
    console.log(i); // i is 0, 1, 2, 3, 4
  });
}

Mistake 2: Assuming const will cause an error in loops

Some developers avoid const in loops thinking they can't reassign the loop variable. They're right that you can't reassign const`, but you can use `const` with `for...of`:

typescript

// This works perfectly
for (const item of collection) {
  // item is const, but it's a new binding each iteration
  item.process(); // fine
  // item = something; // error, and that's good
}

Use `const` with `for...of` whenever possible. Use `let` only for traditional `for` loops where you need to reassign the counter.

Mistake 3: Capturing loop variables in arrow functions without realizing you still need block scope

typescript

// Even with arrow functions, var causes the same bug
for (var i = 0; i < 3; i++) {
  const handler = () => console.log(i); // arrow function doesn't change var scope
  callbacks.push(handler);
}

// Arrow functions don't create their own scope. var is still function-scoped.
// Use let:

for (let i = 0; i < 3; i++) {
  const handler = () => console.log(i); // now i is block-scoped
  callbacks.push(handler);
}

Key takeaways

  • var is function-scoped and hoisted. A var in a loop is a single shared variable for all iterations.
  • let is block-scoped. Each loop iteration gets its own binding, so closures capture the correct value.
  • Callbacks set up in loops with var all see the final value because they close over the same shared variable.
  • The IIFE workaround is historical and no longer necessary; use let instead.
  • Async code (promises, timers, event listeners) amplifies this bug because callbacks execute after the loop finishes.
  • Never use var in modern JavaScript. Use const by default and let only when you need to reassign.
  • This bug is real and happens in production. Using let and const eliminates the entire class of closure-in-loops bugs.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles