loop
closure
javascript

JavaScript closure inside loops – simple practical example

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Closures inside loops are a classic JavaScript source of confusing output, especially in asynchronous code. The issue is not the closure itself, but which variable binding the closure captures. Once you understand how var, let, and function scopes interact, loop behavior becomes predictable.

Why the Classic var Example Fails

Consider delayed logging in a loop.

javascript
1for (var i = 0; i < 3; i++) {
2  setTimeout(function () {
3    console.log(i);
4  }, 100);
5}

Many developers expect output 0, 1, 2, but they get 3, 3, 3.

Reason:

  • var is function-scoped, not block-scoped.
  • Each callback closes over the same i binding.
  • Loop finishes before callbacks execute, leaving i equal to 3.

So callbacks are correct according to captured binding, not intuitive iteration value.

Fix One: Use let in Loop Header

let creates a new binding per iteration in loop block scope.

javascript
1for (let i = 0; i < 3; i++) {
2  setTimeout(function () {
3    console.log(i);
4  }, 100);
5}

Now output is 0, 1, 2 because each callback closes over a distinct iteration binding.

In modern JavaScript, this is usually the cleanest solution.

Fix Two: Capture Value via Function Argument

In older environments where let was unavailable, developers used function wrappers.

javascript
1for (var i = 0; i < 3; i++) {
2  (function (current) {
3    setTimeout(function () {
4      console.log(current);
5    }, 100);
6  })(i);
7}

The wrapper receives current loop value and creates a new scope for each iteration.

Fix Three: Callback Parameters in Timers

Timer APIs let you pass extra callback arguments.

javascript
1for (var i = 0; i < 3; i++) {
2  setTimeout(function (current) {
3    console.log(current);
4  }, 100, i);
5}

This avoids wrapper syntax and still preserves per-iteration values.

Practical UI Example with Event Handlers

Closure-loop issues appear often in DOM event binding.

javascript
1const buttons = document.querySelectorAll("button");
2
3for (var i = 0; i < buttons.length; i++) {
4  buttons[i].addEventListener("click", function () {
5    console.log("clicked index", i);
6  });
7}

All handlers log same final index in var version. Use let or local constant.

javascript
1for (let i = 0; i < buttons.length; i++) {
2  buttons[i].addEventListener("click", function () {
3    console.log("clicked index", i);
4  });
5}

This is one of the most frequent real-world closure bugs in front-end code.

Async and Promise Loops

The same capture rule applies in async loops.

javascript
1for (var i = 0; i < 3; i++) {
2  Promise.resolve().then(function () {
3    console.log(i);
4  });
5}

Again, output is final value repeated. Switching to let fixes it.

javascript
1for (let i = 0; i < 3; i++) {
2  Promise.resolve().then(function () {
3    console.log(i);
4  });
5}

If asynchronous work depends on iteration values, always inspect binding strategy first.

Closure Is Useful, Not Just a Bug Source

Closures are fundamental and powerful when used intentionally.

javascript
1function makeCounter(start) {
2  let value = start;
3  return function () {
4    value += 1;
5    return value;
6  };
7}
8
9const next = makeCounter(10);
10console.log(next());
11console.log(next());

Here closure preserves private state correctly. The loop issue comes from unintentional shared binding, not from closure concept itself.

Choosing Patterns in Production Code

Practical recommendations:

  • prefer let and const for new code
  • avoid var in loop headers unless legacy compatibility requires it
  • keep callbacks small and test output explicitly
  • use linter rules that disallow var in modern codebases

Clear binding choices improve reliability more than clever callback tricks.

Debugging Strategy

When loop callbacks show wrong values, check these in order:

  1. whether loop variable uses var or let
  2. whether callback executes later than loop completion
  3. whether closure captures mutable outer value
  4. whether value should be copied into local constant

This systematic check solves most closure-loop bugs quickly.

Common Pitfalls

A common pitfall is blaming timer delay order when the real issue is shared var binding. Another is mixing var and let in the same module and creating inconsistent closure behavior. Teams also often forget closure capture rules in event handlers and promises, then spend time debugging UI state that appears random. Finally, using complex wrapper patterns when simple let would be clearer reduces maintainability.

Summary

  • Closure-loop bugs usually come from shared var bindings.
  • let creates per-iteration bindings and is the preferred modern fix.
  • Wrapper functions and timer arguments are valid fallback patterns.
  • The same capture rules apply to events, timers, and promises.
  • Closures are powerful when used intentionally with clear binding scope.

Course illustration
Course illustration

All Rights Reserved.