JavaScript ·
JavaScript setTimeout in a loop prints the same value (var)
Three timers all logged job 3 - undefined instead of the queued jobs. var shares one i across every callback; let ends it. Fix plus the ESLint rule.
Test yourself before reading - the write-up below gives it away.
// Announce each queued job, one per second.
var jobs = ["backup", "resize", "email"];
for (var i = 0; i < jobs.length; i++) { setTimeout(function () { console.log("job " + i + ": " + jobs[i]);}, (i + 1) * 1000);
}
// expected: job 0: backup, job 1: resize, job 2: email
// actual: job 3: undefined (three times) (!)
Three lines of output, all identical: job 3: undefined. The queue held three real jobs with three real names, the delays staggered a clean second apart, and every single timer announced a fourth job that does not exist. Whatever went wrong went wrong three times in unison.
Here is the announcer, one timer per queued job:
// Announce each queued job, one per second.
var jobs = ["backup", "resize", "email"];
for (var i = 0; i < jobs.length; i++) {
setTimeout(function () {
console.log("job " + i + ": " + jobs[i]);
}, (i + 1) * 1000);
}
// expected: job 0: backup, job 1: resize, job 2: email
// actual: job 3: undefined (three times) (!)
Why does setTimeout in a loop print the same value?
Line 3 is the bug: var i is function-scoped, so the loop creates one single i and all three callbacks close over that same variable. Timers fire only after the loop has finished, and the loop finishes by pushing i to 3, so each callback reads i as 3 and jobs[3] as undefined. Declare the counter with let, which gives every iteration its own i.
The callbacks run later, and var left them one i to share
Two facts gang up here. First, setTimeout never interrupts running code: the callbacks sit in the task queue until the current script finishes, which means the entire loop is done before the first timer fires. Second, a closure captures the variable itself, not the value it held. With var, "the variable itself" is one binding hoisted to function (or script) scope; all three functions hold a reference to the same slot. By firing time that slot contains the value that made the loop stop: 3. The exit condition guarantees the surprise, which is why the printed number is always one past the last valid index and the array read comes back undefined.
The nested callback on lines 4-6 is where the eye goes, and it is innocent. So is the (i + 1) * 1000 delay math: the delay is an argument, evaluated during the loop while i is still 0, 1, 2, which is why the three wrong announcements still arrive politely spaced one second apart. Correctly staggered timing is half of what keeps this bug hidden: the schedule behaves, so the timer code looks audited, and suspicion lands on the data instead.
let ends it because ECMA-262 gives a let loop head special treatment: the for statement creates a fresh binding for each iteration and copies the counter's current value into it. Three iterations, three separate i variables, each frozen mid-count inside its own closure. A let loop head is the one place in the language that conjures a new variable on every pass.
The reminder emails that all greeted customer three
Picture a drip campaign runner: an array of signups, a loop that schedules one personalized reminder per customer, each a day later than the last. The send times land beautifully, which is what everyone checks. The bodies all render from customers[i] at send time, so every email greets the same person, and the person it greets is undefined wearing a template. The first day's send looks like a template hiccup. By day three the pattern is obvious, the "personalization service" gets blamed, and the loop that scheduled everything, which ran and exited days ago, is the last code anyone rereads.
The fix: let gives each pass its own counter
One keyword:
for (let i = 0; i < jobs.length; i++) {
setTimeout(function () {
console.log("job " + i + ": " + jobs[i]);
}, (i + 1) * 1000);
}
// job 0: backup, job 1: resize, job 2: email
If the surrounding code cannot move off var, setTimeout accepts extra arguments after the delay and passes them to the callback: setTimeout(announce, delay, i) hands the callback i's value at scheduling time, which is a copy and therefore safe. jobs.forEach((job, i) => ...) gets you the same per-call scope for free.
The tempting wrong fix is to "snapshot" the counter inside the body:
for (var i = 0; i < jobs.length; i++) {
var j = i; // still one variable
setTimeout(function () {
console.log("job " + j + ": " + jobs[j]);
}, 10);
}
// job 2: email (three times)
var j hoists to the same function scope as var i, so there is still exactly one j, rewritten every pass. The output even changes shape, from job 3: undefined to a chorus of the final real job, which can trick you into thinking you are one tweak away. You are one keyword away: make it let j = i (or fix the loop head) and the copy becomes real.
Where var loops still hide in a let codebase
Two ESLint rules bracket this bug. no-var retires the function-scoped declaration wholesale, and no-loop-func flags any function created inside a loop that references outer-scope variables, which catches the pattern even when the declaration hides far from the loop. For a quick manual sweep:
grep -rnE 'for \(var ' --include=*.js .
Old jQuery-era files, copy-pasted snippets, and transpiler output are where for (var survives in 2026. The same one-shared-variable trap exists across languages: Python closures read the loop variable late in the lambda-in-a-loop bug, and Go only ended its version of it in 1.22, as told in the goroutines-see-one-variable write-up.
Related bugs
Async order of operations produces a sibling burn when an async callback handed to forEach never gets awaited, another case of code finishing before its work does. More JavaScript traps live on the JavaScript bug hub, with the full set at bug write-ups.
Next time three timers speak in unison, skip straight to the loop head and count how many is it actually makes.