A callback logs an old value. Another function sees the latest value. Two event listeners, created by the same line of code, both run after a single event. All three behaviors make sense once you ask one question: which variable binding does this particular function reference?
A closure is a function together with access to the lexical environment where it was created. “Lexical” means the scopes are determined by where the function is written, not by who calls it. The function can keep using those bindings after the surrounding call returns. Every JavaScript function is created with its surrounding lexical environment; returning a nested function just makes that relationship easier to see.
The important distinction is that a closure does not automatically freeze the current value of every variable. It retains access to bindings. A binding may change, or another function may have been created in a different invocation with a different binding.
When do closures share a variable, and when do they get separate ones?
Consider a counter with two methods:
function createCounter() {
let count = 0;
return {
increment() {
return ++count;
},
read() {
return count;
},
};
}
const first = createCounter();
const second = createCounter();
console.log(first.read()); // 0
console.log(first.increment()); // 1
console.log(first.increment()); // 2
console.log(first.read()); // 2
console.log(second.read()); // 0
Both methods returned by the same call to createCounter() reference its count binding. That is why read() observes changes made by increment(). Calling the factory again creates another environment; second has an independent count.
Notice what is not happening: increment() does not pass count into read(), and read() does not store the number 0 inside its function body. The methods reach the same enclosing binding when called.
There is a useful contrast:
const capturedNumber = first.read(); // 2
first.increment(); // 3
console.log(capturedNumber); // 2
console.log(first.read()); // 3
capturedNumber is an ordinary number assigned from a function's result. It is not a closure reading count later. Confusing a stored result with an enclosed binding is a common source of misleading explanations.
For the language-level model, see MDN's closure guide and the JavaScript execution model.
Why does a callback sometimes see an old value?
An old callback may be working correctly: it was created during an earlier call, with a binding that was never updated.
function makeReader(status) {
return () => status;
}
const oldReader = makeReader("idle");
const newReader = makeReader("ready");
console.log(oldReader()); // "idle"
console.log(newReader()); // "ready"
Each makeReader() call creates its own status parameter binding. Creating newReader does not reach back and change the binding used by oldReader.
Compare that with a single environment deliberately exposing a way to update its binding:
function makeStore(initial) {
let status = initial;
return {
read: () => status,
set(next) {
status = next;
},
};
}
const store = makeStore("idle");
const reader = store.read;
store.set("ready");
console.log(reader()); // "ready"
The callback is not inherently “stale” or “live”; the relevant facts are when it was created, which environment it belongs to, and whether the referenced binding changes.
This distinction matters in React, but React adds its own rendering semantics. Each render supplies its event handlers with that render's state snapshot. A handler created during an earlier render can therefore read earlier state even after a later render exists. That is different from mutating a let binding shared by two methods in one factory call. Read React's state-as-a-snapshot explanation before attempting to fix a React-specific stale handler by manually mutating local variables.
The classic loop bug is about one binding versus three
Here is a reproducible way to see the difference:
const withVar = [];
for (var i = 0; i < 3; i++) {
withVar.push(() => i);
}
console.log(withVar.map((read) => read())); // [3, 3, 3]
const withLet = [];
for (let j = 0; j < 3; j++) {
withLet.push(() => j);
}
console.log(withLet.map((read) => read())); // [0, 1, 2]
The var declaration does not create a new binding per loop iteration. Here all three callbacks read the same i after the loop completes. In a classic browser script a top-level var is global; in JavaScript modules and Node.js CommonJS files, top-level scope behaves differently. That distinction does not change this loop result. A let declaration in the for loop header establishes a distinct binding for each iteration, so each callback reads its own j. Neither set of callbacks ran when it was added to the array.
Replacing var with let is the appropriate fix for this bug. It is not a general fix for every callback that reads old data: the previous makeReader() example already uses distinct environments without a loop.
One event, two callbacks: closures and listener cleanup
Suppose an initialization routine runs twice. Each run creates a new function with a new private counter and registers that function as a listener. A single event will now produce two updates.
This example works with the browser's EventTarget interface; it needs no actual button or DOM markup:
const target = new EventTarget();
const seen = [];
function attachTracker(target, label) {
let count = 0;
function onPing() {
count += 1;
seen.push(label + ": " + count);
}
target.addEventListener("ping", onPing);
return () => {
target.removeEventListener("ping", onPing);
};
}
const stopA = attachTracker(target, "A");
const stopB = attachTracker(target, "B");
target.dispatchEvent(new Event("ping"));
console.log(seen); // ["A: 1", "B: 1"]
stopA();
target.dispatchEvent(new Event("ping"));
console.log(seen); // ["A: 1", "B: 1", "B: 2"]
stopB();
target.dispatchEvent(new Event("ping"));
console.log(seen); // ["A: 1", "B: 1", "B: 2"]
The same attachTracker source code executes twice, but it creates two different onPing function objects and two different count bindings. They are not duplicate registrations of the same function object. Each listener needs its own cleanup, and removeEventListener must receive the listener reference originally registered (with a matching capture setting).
If cleanup is omitted during repeated setup, callbacks can keep firing when the application no longer expects them. A reachable listener may also retain references it uses, but creating a closure is not itself a memory leak; actual retention depends on reachability and lifecycle. For the API details see addEventListener and removeEventListener. For a separate look at reachability, follow the JavaScript memory-management guide. If the issue involves registering many DOM handlers, event delegation is another design option, not a substitute for lifecycle cleanup.
How to debug the binding a callback actually reads
When an unexpected value appears in an asynchronous or event-driven callback, investigate in this order:
- Find the creation point. Which call, render, or loop iteration created the callback that actually ran?
- Identify the declaration. Is the referenced name a parameter, a
letbinding, aconstvalue, avarshared across iterations, or a property on an object? - Find the update. Does the update modify that same binding or object, or does it create a new variable/function in another invocation?
- Check registration and cleanup. Is more than one callback still registered? Can the old reference be removed?
- Inspect at execution time. Set a breakpoint inside the callback, trigger it, and inspect the Scope pane in Chrome DevTools. Compare the closure's bindings with the current caller's values. Chrome documents the Scope pane and closure variables.
Do not confuse closure behavior with callback scheduling. A closure explains which values a callback can access; the event loop explains when a queued callback runs. If ordering is the problem, use the JavaScript event-loop guide.
A useful rule of thumb
When a callback reads the “wrong” value, resist the shortcut “closures copy values.” Instead draw the relevant function calls as separate boxes and ask which declaration each callback points to. Two callbacks may share one mutable binding; two executions of the same factory usually create separate bindings. Loop declarations, re-renders, and event-listener lifecycles determine which case you are actually debugging.
A closure is a language mechanism, not a state-management strategy or a cleanup mechanism on its own. Understanding the binding model helps you choose the right fix rather than replacing syntax blindly.
