Closures in Loops: Understanding Stale Closures
AI generated
JS
() =>
JavaScript · Closures · Debugging
Closures in Loops
understanding stale closures as a pitfall

A loop registers five buttons, every click logs the same number instead of the expected sequence. This pattern is called a stale closure, and it hits not just classic var loops but also modern event handlers and asynchronous code. This article shows where stale closures come from, why let often fixes the problem, and how to systematically debug the remaining cases.

17 min read Closures · var vs let · event handlers · async ES2015+ · debugging

1. What a closure is and how a stale closure is created

A closure is created whenever a function accesses variables from its enclosing scope and keeps them alive in memory beyond the end of the outer code's execution. This is one of the most powerful concepts in JavaScript, because it lets functions carry their own private state without needing global variables. But the exact same property that makes closures so useful becomes a trap the moment several functions reference the same variable while its value changes in the meantime.

A stale closure occurs when a function runs at a later point in time and, in doing so, does not receive the value one would have expected at the time the function was defined, but instead an already changed value of the same variable. So the term stale closure does not describe a flaw in the language mechanism itself, it describes a mistaken expectation on the developer's part about which value the closure actually sees at execution time. The closure works technically correctly, it just reads a different value than intended.

The following sections walk through the most common situations where stale closures show up in practice: classic loops using var, event handlers registered inside a loop, and asynchronous code with delayed responses. Each section includes a concrete example and a robust fix, so stale closures stop causing hours of debugging.

2. The classic var pitfall: why loops print the wrong number

The best known example of a stale closure is a for loop using var, where a setTimeout callback is registered on every iteration. Because var is function scoped rather than block scoped, only a single variable exists for the entire loop, not a new instance per pass. Every registered callback therefore references the same memory slot, and by the time the timers finally fire, the loop has long since finished and the variable holds its last assigned value.

The result surprises many developers on first contact: instead of the expected numbers zero through four, the number five appears five times in a row. The closure is technically flawless, it reads exactly the value the variable holds at execution time, which is precisely the definition of a stale closure. This pattern shows up not only with setTimeout, but anywhere a function runs later than the iteration itself, for example in asynchronous callbacks or delayed UI updates.


// Classic stale closure with var
for (var i = 0; i < 5; i++) {
  setTimeout(() => {
    console.log(i); // prints 5, five times — not 0, 1, 2, 3, 4
  }, 100);
}
// Reason: var is function-scoped, only one "i" exists for the whole loop.
// By the time the timers fire, the loop has already finished with i === 5.

3. let instead of var: how block scoping fixes the problem

The introduction of let in ES2015 solved exactly this problem at the language level, though in a more subtle way than many developers assume. let is block scoped, but the decisive detail is not the scoping alone, it is that the specification for for loops using let creates a brand new binding on every iteration. Each iteration receives its own fresh copy of the loop variable, and the closure created inside that iteration references exactly that copy, not one shared variable.

As a result, the stale closure disappears completely just by swapping var for let, with no extra code required. That makes let the simplest and today the recommended fix for this class of bugs. Still, it is important to understand that this is a peculiarity of for loop syntax specifically, not a general property of let. If the same variable is reused outside a loop and captured repeatedly in closures, a stale closure can reappear even with let, once the variable is mutated between the closures being created.


// Fixed with let: a fresh binding per iteration
for (let i = 0; i < 5; i++) {
  setTimeout(() => {
    console.log(i); // prints 0, 1, 2, 3, 4 — each closure sees its own iteration
  }, 100);
}

// The fresh-binding-per-iteration rule is specific to for-loops.
// Reusing one mutable "let" variable across manually created closures
// still produces a stale closure:
let counter = 0;
const callbacks = [];
for (let n = 0; n < 3; n++) {
  callbacks.push(() => console.log(counter)); // all three read the SAME "counter"
}
counter = 99;
callbacks.forEach(cb => cb()); // 99, 99, 99 — stale closure is back

4. Event handlers in loops: the same bug, a different wrapper

When a loop registers an event handler for every item in a list, exactly the same pattern as the setTimeout example shows up, only with a click instead of a timer as the trigger. Anyone who dynamically generates a row of buttons and registers a click handler per button in the same loop, referencing the loop variable, produces a stale closure when using var that logs the same, wrong index on every click, no matter which button was actually clicked.

The difference from the timer example is purely timing related: the click can happen seconds or minutes after the loop, while the timer usually fires milliseconds later. The underlying stale closure problem stays identical, because the trigger fires only after the loop has fully completed in both cases. Switching to let reliably fixes this here too, and alternatively the current index can be stored explicitly as an attribute on the element and read from the handler via event.currentTarget, which additionally works regardless of the chosen variable declaration.


const buttons = document.querySelectorAll('.item-button');

// WRONG: stale closure, every handler logs the same, final index
for (var i = 0; i < buttons.length; i++) {
  buttons[i].addEventListener('click', () => {
    console.log(`Button ${i} clicked`); // always the last index
  });
}

// RIGHT #1: let creates a fresh binding per iteration
for (let i = 0; i < buttons.length; i++) {
  buttons[i].addEventListener('click', () => {
    console.log(`Button ${i} clicked`); // correct index every time
  });
}

// RIGHT #2: read the index from the DOM instead of relying on the closure
buttons.forEach((btn, index) => {
  btn.dataset.index = String(index);
  btn.addEventListener('click', (event) => {
    console.log(`Button ${event.currentTarget.dataset.index} clicked`);
  });
});

5. Stale closures in asynchronous code: race conditions with fetch

A particularly tricky variant of the stale closure shows up in asynchronous code when a component fires multiple requests in quick succession, for example a live search that starts a new request on every keystroke. Each of these requests captures, inside its response callback, the search term that was valid at the time it was triggered. But responses are not guaranteed to arrive in the same order the requests were started, so an older, slower response can overwrite a newer, already more current display.

Strictly speaking, this is not a stale closure of the variable itself, it is a stale closure of the entire request context: the callback correctly carries the search term of its own request, but that context is already outdated by the time the response arrives, because the user has kept typing in the meantime. The robust fix is either comparing the search term stored in the callback against the currently valid value before updating the UI, or an AbortController that actively cancels outdated requests before their stale closure ever runs.


let currentQuery = '';

async function search(query) {
  currentQuery = query;
  const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
  const results = await response.json();

  // Guard against a stale closure: only render if this is still the latest query
  if (query !== currentQuery) {
    return; // an older, slower response arrived after a newer one
  }
  renderResults(results);
}

// Even more robust: cancel stale requests with AbortController
let controller = null;

async function searchWithAbort(query) {
  controller?.abort(); // cancel any in-flight previous request
  controller = new AbortController();
  const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
    signal: controller.signal
  });
  const results = await response.json();
  renderResults(results);
}

6. The IIFE pattern: the classic fix before let

Before let was introduced, the immediately invoked function expression, or IIFE, was the standard fix for avoiding stale closures in loops. The trick is to call an immediately executed function on every iteration and pass the current loop variable in as a parameter. Because function parameters correspond to a fresh binding on every call, a separate, immutable copy of the variable is created inside the IIFE, regardless of how often the outer loop variable is later mutated.

Historically, the IIFE pattern remains relevant mainly when reading and maintaining older codebases that still rely on var and ES5 syntax and cannot easily be migrated for organizational reasons. In new code, the pattern is only worth using in rare exceptions today, for example when a library mandates a fixed API signature without block scoping. For all new projects, let remains the simpler, more readable, and equally reliable fix against the same class of stale closures.


// Pre-ES2015 fix using an IIFE to capture the current value of "i"
for (var i = 0; i < 5; i++) {
  (function (capturedIndex) {
    setTimeout(() => {
      console.log(capturedIndex); // 0, 1, 2, 3, 4 — each IIFE gets its own copy
    }, 100);
  })(i);
}

// Equivalent modern code, no IIFE needed:
for (let i = 0; i < 5; i++) {
  setTimeout(() => console.log(i), 100);
}

7. Closures and state: intentional versus accidental encapsulation

Not every closure that reads an outdated value is automatically a bug. The module pattern deliberately uses closures to encapsulate private state that persists across multiple function calls, for example in a counter that gets incremented by one on every call. In this case it is exactly the desired behavior that all returned functions reference the same, shared variable, because they are meant to jointly manage the same state.

The difference between intentional encapsulation and a stale closure lies purely in the developer's expectation: with the module pattern you expect shared, mutable state across multiple calls, whereas in a loop you expect isolated, independent values per iteration. Anyone who deliberately keeps these two cases apart can immediately tell, during code review, whether a shared variable in a closure is a feature or a bug, instead of blanket suspecting every closure.


// Intentional shared state via closure — this is a feature, not a stale closure
function createCounter() {
  let count = 0; // private state, shared across all returned functions
  return {
    increment: () => ++count,
    decrement: () => --count,
    value: () => count
  };
}

const counter = createCounter();
counter.increment();
counter.increment();
console.log(counter.value()); // 2 — shared closure state, intentional

// Contrast: a loop where each callback should see its OWN value, not shared state
const counters = [];
for (let i = 0; i < 3; i++) {
  counters.push(createCounter()); // one independent closure instance per iteration
}

8. Debugging techniques: spotting stale closures in code

The fastest way to confirm a stale closure is a breakpoint placed directly inside the callback and a look at the closure panel in Chrome DevTools, which displays every captured variable in the scope section along with its actual value at execution time. If the value shown obviously does not match the expected iteration, for example always the last index instead of the current one, the cause is almost always a shared variable captured multiple times through var or through manual reuse.

ESLint rules like no-loop-func flag function definitions inside loops right in the editor and preemptively point at potential stale closures, long before the code ever runs. A simple test also helps: run the suspect callback with an artificial delay, for example through an extra setTimeout with a larger delay, and check whether the observed behavior changes compared to immediate execution. If it does, that is a strong indication of a stale closure, since the variable has clearly mutated between the closure's definition and its execution.

9. Closure patterns compared side by side

Depending on the situation, a shared variable in a closure is either desired or the cause of a stale closure, and choosing the right pattern depends directly on whether every iteration needs its own, isolated value.

Situation Stale closure risk Recommended pattern Why it works
for loop with timer High with var let instead of var Fresh binding per iteration
Event handler in a loop High with var let or dataset attribute Value readable regardless of declaration type
Live search with fetch High, regardless of var/let AbortController or query comparison Outdated responses get discarded
Module pattern / counter No risk, intended state Keep the shared closure variable State should persist across calls
Legacy ES5 code High with var IIFE with parameter passing Parameter creates a fresh binding per call

The table shows that the closure itself is not the problem, the real question is whether a shared or an isolated variable is required. Anyone who makes this distinction from the start avoids most stale closures instead of only tracking them down through breakpoints and console output once they have already caused a bug.

Mironsoft

JavaScript debugging, code reviews and frontend architecture

Are stale closures costing you debugging time over and over?

We audit existing JavaScript code for risky closure patterns in loops, event handlers and asynchronous code and replace them with robust, clearly tested patterns.

Code Review

Targeted search for var loops and risky closure patterns

Refactoring

let migration, AbortController integration, clean encapsulation

Linting Setup

ESLint rules against function definitions inside loops

10. Summary

Stale closures appear whenever a function runs later than expected and, in doing so, reads a variable that has already changed between definition and execution. The classic case is a for loop using var, where every registered callback references the same, function scoped variable and ends up printing the same, final value. Switching to let fixes this in most cases automatically, because every iteration of a for loop using let gets its own, fresh binding.

The same basic pattern reappears in event handlers and asynchronous code, often in the form of race conditions where an outdated response overwrites a more current one. AbortController and explicit query comparisons reliably guard against that. It remains important to tell a genuine stale closure apart from an intentionally shared state variable in the module pattern, because not every shared closure variable is a bug. Breakpoints in the DevTools closure panel and the ESLint rule no-loop-func help quickly identify the remaining cases.

Stale Closures in JavaScript, the essentials at a glance

Cause

A closure reads a variable that has already changed between its definition and its later execution.

Classic Fix

let instead of var in loops, every iteration automatically gets its own binding.

Async Variant

AbortController or query comparison prevent outdated responses from overwriting current ones.

Debugging

Check the closure panel in DevTools, enable the ESLint rule no-loop-func.

11. FAQ: Stale Closures in JavaScript

1What exactly is a stale closure?
A closure that reads an already changed value, because the captured variable mutated between its definition and execution.
2Why does a var loop always print the same number?
var is function scoped, all callbacks share one variable holding the last assigned value.
3Why does let fix it in for loops?
Every iteration gets its own, fresh binding of the variable, so each closure references its own copy.
4Can a stale closure still happen with let?
Yes, outside the for-loop rule, when a let variable is manually captured multiple times and then mutated.
5What is the IIFE pattern?
An immediately invoked function that takes the loop variable as a parameter. Only relevant for ES5 legacy code today.
6How does a stale closure appear with fetch?
Responses do not arrive in start order, an older response can overwrite a newer display.
7How does AbortController help against stale closures?
Outdated requests are actively cancelled before their response and stale closure can run.
8Is every shared closure variable a bug?
No, the module pattern relies on shared state intentionally. What matters is whether isolated or shared values are expected.
9Which ESLint rule helps against stale closures?
no-loop-func flags function definitions inside loops directly in the editor.
10How do I find stale closures in DevTools?
Set a breakpoint in the callback, check the closure panel in the scope section, and inspect the actual value of the variable.