Why setTimeout Sees Stale Values: Five Closure Traps Every JavaScript Dev Hits
Problem Scenario
When writing loops, callbacks, or timers, you often encounter the mystery of "incorrect variable values":
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 1000); // 3 3 3 ???
}
Or:
function fetchData(id) {
const data = cache[id];
setTimeout(() => {
sendMetric(data); // Is it using a "snapshot" or the "latest" value?
}, 2000);
}
The code looks perfectly reasonable, but the result is completely wrong—the value at the time the timer/callback executes is not at all what you had in mind when writing the code. This is one of the most classic and subtle pitfalls in frontend development.
Cause Analysis
The root cause is that closures capture a variable's "reference/scope", not its "value". The callback in setTimeout executes at some future moment, and at that time it reads the value of the variable captured by the closure at that current moment, not the value at the time of definition.
Specifically, there are two types of pitfalls:
- The
varfunction-scope trap:varhas no block-level scope; the entireforloop shares the samei. When the callback executes, theforloop has long finished, andiis already its final value (3), so it prints 3 every time. - The delayed-read trap: The closure captures the variable itself. If you use
let/objects in a place where the value changes, the callback reads the value at the moment of execution, not the value at the momentsetTimeoutwas called.
Solutions
Pitfall 1: for + var → Fix with let (most common)
// Wrong
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 1000); // 3 3 3
}
// Correct: let has block-level scope, creating an independent binding per iteration
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 1000); // 0 1 2
}
let creates a new binding for each iteration; the callback captures the i of that specific iteration, and the problem disappears.
Pitfall 2: Closure captures a reference → Use parameters/IIFE to capture the "value"
If you are using var or need to explicitly capture a value, use an IIFE or extra parameter to freeze it:
// Freeze with IIFE
for (var i = 0; i < 3; i++) {
(function (index) {
setTimeout(() => console.log(index), 1000); // 0 1 2
})(i);
}
// Or function parameter (also freezes)
[0, 1, 2].forEach((index) => {
setTimeout(() => console.log(index), 1000); // 0 1 2
});
Pitfall 3: Reading the "latest" instead of a "snapshot" (reverse requirement)
Sometimes you want to read the latest value (intentionally), but the closure by default captures the binding at the time of declaration. In this case, declare with let in the outer scope:
let latest = { count: 0 };
setTimeout(() => {
// What is read here is the object latest points to at execution time
console.log(latest); // If external code changed latest, this is the new one
}, 1000);
latest = { count: 99 }; // The timer callback reads this
Note: This is the opposite of "freezing"—it depends on which one you want. For values that may change later, use an outer let reference; for fixed values, freeze with parameters.
Pitfall 4: Object properties are not snapshots
const obj = { val: 1 };
setTimeout(() => console.log(obj.val), 1000);
obj.val = 99; // Outputs 99, not 1
The closure captures the obj reference; obj.val is always the latest value at access time. To freeze, destructure:
const obj = { val: 1 };
const snapshot = obj.val;
setTimeout(() => console.log(snapshot), 1000); // 1
obj.val = 99;
Pitfall 5: Stale closures in React (useEffect/useCallback)
The most common pitfall in React components: incomplete dependencies cause the callback to read stale state:
function Counter() {
const [count, setCount] = useState(0);
// ❌ Missing count dependency; callback captures count(0) from the first render, always the old value
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, []);
// ✅ Use functional update, not dependent on external value
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id);
}, []);
}
Key point: setCount(count + 1) captures count, but because the dependency array is [], this callback is the closure from the first render, and count is always 0. Use a functional update c => c + 1 or add count to the dependency array to read the latest value.
Key Takeaways
- Closures capture variable scope, not values—the callback reads the value at the moment of execution.
varhas no block-level scope →forloops share the same variable → useletto create independent bindings per iteration.- To freeze the current value: use IIFE / function parameters / early destructuring to snapshot the value.
- To read the latest value: use an outer
letreference; don't freeze prematurely. - Object properties are always the value at access time; freeze by destructuring early if needed.
- Stale closures in React:
useEffect/useCallbackwith incomplete dependencies → callback captures old state → use functional updates or complete the dependencies. - Remember one sentence: "The callback executes in the future and reads the value of the present moment"—all closure timer pitfalls stem from this one sentence.
In one sentence: Closures are powerful, but they "bind to scope, not to value." When writing
setTimeout/callbacks, first ask yourself: Do I want a frozen snapshot, or the latest dynamic value? If you're clear on that, you won't step into the trap.
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
Great article, learned a lot!