Lesson 11 · 12 min read
setTimeout and Closures: The Interview Classic
One loop, one setTimeout, one wrong answer almost everyone gives the first time. This lesson is dedicated entirely to the single most-asked closures question in JavaScript interviews — and four different, genuinely correct ways to fix it.
On this page
- Key words in plain English
- The problem, stated precisely
- The wake-up call sheet — an analogy
- The naive attempt — and why it fails
- Tracing it, step by step
- Fix 1 — swap var for let
- Fix 2 — wrap it in a closure (IIFE)
- Fix 3 — pass i as a function parameter
- Fix 4 — pre-bind the argument
- All four, side by side
- A note on setTimeout's timing
- See it live — DevTools
- Common misconceptions
- Interview questions
Key Words In Plain English
- Closure is a function that remembers the variables from the place where it was created and keeps using them later (Lesson 10).
setTimeoutis a built-in function that runs a function once, after a delay in milliseconds. It does not wait. It schedules the work and returns at once.- Callback is a function you give to other code so that it can call it later. The function you pass to
setTimeoutis a callback. - Arrow function is a short way to write a function:
() => console.log(i). - Synchronous code is code that runs line by line, each line finishing before the next starts. A timer callback is not part of it. It runs later.
- Call stack is the list of function calls running right now. The engine runs one thing at a time, from the top of the stack.
- Event loop is the part of the runtime that waits until the call stack is empty and then moves the next waiting callback onto it. Lesson 14 explains it.
- Block scope means a variable exists only inside the nearest pair of braces. Function scope means it exists in the whole function.
letis block scoped andvaris function scoped (Lesson 9). - Binding is the link between a variable name and its storage. With
letin a loop head, each pass of the loop gets its own binding. - IIFE (Immediately Invoked Function Expression) is a function that is defined and called in the same step:
(function () { ... })(). .bind()is a function method that returns a new function with some arguments already filled in.f.bind(null, 5)is a copy offthat always gets 5 as its first argument. The first value (nullhere) setsthis, andconsole.logdoes not need it.- Variable Environment is the engine's record of the variables made by one function call. Every call gets its own new record (Lesson 4).
The Problem, Stated Precisely
Print the numbers 1 through 5, one per second — 1 after 1 second, 2 after 2 seconds, and so on — using a loop and setTimeout. It sounds trivial. The obvious first attempt is wrong, and understanding exactly why it's wrong is what makes this question worth asking.
The Wake-Up Call Sheet — An Analogy
A guest asks the front desk for five wake-up calls, one every hour, for rooms 1 through 5. The desk clerk scribbles the room number on a single sticky note, reuses that same note for every request, and hands it to the wake-up-call team to act on later, one hour at a time.
By the time the first wake-up call actually happens an hour later, the clerk has already overwritten that same sticky note five times — it now just says “6”. Every single wake-up call rings for room 6, because there was only ever one note, shared and repeatedly overwritten.
The fix, in every version below, is the same idea: give each wake-up call its own separate note, written and sealed the moment the request is made — not one shared note read later, after all five requests have already been scribbled over it.
The Naive Attempt — And Why It Fails
for (var i = 1; i <= 5; i++) {
setTimeout(() => {
console.log(i);
}, i * 1000);
}
// Expected: 1, 2, 3, 4, 5 (one per second)
// Actual: 6, 6, 6, 6, 6This is the exact bug from Lesson 10's loop example, just dressed up with increasing delays. var is not block-scoped (Lesson 9) — there is only one i shared by the entire loop and by all five arrow functions. The setTimeout delays only control when each callback runs — they don't stop the loop from finishing first, every single time, before any callback fires.
i = 6 · 5 timers set
reads i → 6
reads i → 6
reads i → 6
reads i → 6
reads i → 6
Tracing It, Step By Step
The arrow function is created and handed to setTimeout. It closes over the loop's single i — currently 1. setTimeout returns immediately; nothing waits.
1for (var i = 1; i <= 5; i++) {2setTimeout(() => {3console.log(i);4}, i * 1000);5}6// 6, 6, 6, 6, 6
The loop runs to completion immediately
setTimeoutdoesn't pause the loop — it just hands each callback off to be run later, and the loop moves straight on to the next iteration. All fivesetTimeoutcalls happen within a few milliseconds of each other.i keeps incrementing, in the same shared spot
By the time the loop's condition
i <= 5finally fails,ihas been incremented one last time — to6— and the loop exits.All five callbacks were closures over that one i
None of the five arrow functions got their own copy of
i. Each one just closed over the single, sharedivariable — the same one the loop itself was using.A second (or more) later, each callback finally runs
By now
iis6and will never change again. Every callback reads the same, final, shared value — five wake-up calls, one overwritten sticky note.
Notice this trace doesn't need the full machinery of the event loop (that's Lesson 14) — it only needs one fact: setTimeout's callback always runs later, after the synchronous code around it has already finished. That single fact is enough to explain the entire bug.
Fix 1 — Swap var For let
for (let i = 1; i <= 5; i++) {
setTimeout(() => {
console.log(i);
}, i * 1000);
}
// 1, 2, 3, 4, 5 — one per secondCovered in full in Lesson 10: let creates a brand-new binding of i for every iteration of the loop. Five iterations, five completely independent i variables, five separate closures — each one permanently capturing its own value. This is the modern, default answer, and in real code it's the one you should actually use.
let gives this iteration its own block binding, i = 1. The callback closes over that one.
1for (let i = 1; i <= 5; i++) {2setTimeout(() => {3console.log(i);4}, i * 1000);5}6// 1, 2, 3, 4, 5
Fix 2 — Wrap It In A Closure (IIFE)
Before let existed, this was the standard interview answer, and it still shows up when someone asks “solve it without changing var.” An IIFE (Immediately Invoked Function Expression) creates a brand-new function scope on every iteration, and that new scope is where the closure actually forms:
for (var i = 1; i <= 5; i++) {
(function (capturedI) {
setTimeout(() => {
console.log(capturedI);
}, capturedI * 1000);
})(i);
}
// 1, 2, 3, 4, 5The IIFE runs immediately, on every single iteration, taking the current value of i as its parameter capturedI right then and there. Each of the five IIFE calls gets its own private Variable Environment (Lesson 4) — its own separate capturedI — and the inner setTimeout callback closes over that private copy instead of the shared, looping i.
The IIFE runs immediately with i as its argument. That call gets its own Variable Environment with capturedI = 1. The setTimeout callback is created inside it, so it closes over this capturedI — not the loop's i.
1for (var i = 1; i <= 5; i++) {2(function (capturedI) {3setTimeout(() => {4console.log(capturedI);5}, capturedI * 1000);6})(i);7}8// 1, 2, 3, 4, 5
Fix 3 — Pass i As A Function Parameter
function scheduleLog(n) {
setTimeout(() => {
console.log(n);
}, n * 1000);
}
for (var i = 1; i <= 5; i++) {
scheduleLog(i);
}
// 1, 2, 3, 4, 5Same underlying idea as the IIFE, just cleaner: every call to scheduleLog(i) creates a brand-new function call, and every function call gets its own fresh Variable Environment (Lesson 4) — including its own private copy of the parameter n. Five calls, five independent ns, five correct closures.
scheduleLog(1) is an ordinary call: a fresh Variable Environment with its own parameter n = 1. The callback created inside closes over that n.
1function scheduleLog(n) {2setTimeout(() => {3console.log(n);4}, n * 1000);5}67for (var i = 1; i <= 5; i++) {8scheduleLog(i);9}10// 1, 2, 3, 4, 5
Fix 4 — Pre-Bind The Argument
for (var i = 1; i <= 5; i++) {
setTimeout(
console.log.bind(null, i),
i * 1000
);
}
// 1, 2, 3, 4, 5.bind() creates a new function with i's current value permanently locked in as an argument, right at the moment .bind() is called — not read later when the timer fires. It sidesteps closures entirely by baking the value in up front, rather than relying on scope at all.
.bind(null, 1) is evaluated now. It reads i's current value (1) and returns a new function with that value locked in as its first argument. The new function does not close over i at all.
1for (var i = 1; i <= 5; i++) {2setTimeout(3console.log.bind(null, i),4i * 10005);6}7// 1, 2, 3, 4, 5
All Four, Side By Side
| Fix | What actually creates the new scope | Still uses var? |
|---|---|---|
1. let | A fresh block-scoped binding per loop iteration | No |
| 2. IIFE | A new function call, invoked immediately, per iteration | Yes |
| 3. Helper function | A new function call (not immediately invoked) per iteration | Yes |
4. .bind() | No new scope — the value is baked into the function directly | Yes |
All four are legitimate, correct answers. In real code, reach for let — it's simpler and it's what it was designed for. The other three are worth knowing because interviewers often specifically ask you to solve it without touching var.
A Note On setTimeout's Timing
setTimeout(fn, 1000) does not guarantee the callback fires at exactly 1000ms — it guarantees at least 1000ms. If the call stack is busy running other code when the 1000ms mark arrives, the callback waits until the stack is free. The full reason why — the callback queue, the microtask queue, and the event loop that connects them — is coming in Lesson 14. For this lesson, the relative ordering (1 before 2 before 3...) is guaranteed even if the exact millisecond isn't.
See It Live — DevTools
Paste the naive var version
Run it in the console and watch the logs arrive one per second, each one showing
6. (The delays use the value ofiat the moment eachsetTimeoutis called, so they are still 1, 2, 3, 4 and 5 seconds.)Set a breakpoint inside the arrow function
On the
console.log(i)line, then re-run.Check the Closure section each time it pauses
All five pauses will show the exact same
i, already at6, in theGlobalscope (it would be aClosureentry named after the function if the loop were inside a function).Repeat with the let version
This time each pause shows a different
i(1, then 2, and so on) in its ownClosureentry — a freshiper iteration, exactly as Lesson 10 described.
Common Misconceptions
“setTimeout(fn, 0) or a longer delay pauses the loop until it fires.” No — setTimeout never blocks anything. It schedules the callback and returns immediately; the loop keeps running without waiting.
“The IIFE fix works because it runs the code earlier.” It doesn't change when the console.log runs — it changes which variable the callback closes over, by creating a brand-new scope per iteration.
“You need let to fix this — there's no other way.” let is the simplest fix, not the only one. Any technique that creates a fresh scope per iteration works, which is why the IIFE and helper-function fixes are still asked about.
Interview Questions
Why does the naive var + setTimeout loop print the same number every time?
Because var creates one shared variable for the entire loop. All the callbacks close over that same variable, and by the time any of them run, the loop has already finished and the variable holds its final value.
Name three ways to fix it without simply switching var to let.
Wrap the body in an IIFE that takes the current value as a parameter; extract the body into a helper function and call it with the current value; or use .bind() to lock in the current value as an argument.
Why does passing i into a helper function fix the bug?
Every function call gets its own private Variable Environment (Lesson 4). Each call to the helper creates a new, independent copy of its parameter, so each returned/scheduled closure captures its own value instead of a shared one.
Does simply adding let fix a nested var inside the loop body too?
No — only the loop's own control variable becomes per-iteration with let. A var declared inside the loop body is still function/global-scoped and still shared across iterations, exactly as in Lesson 9.
Does setTimeout(fn, 1000) guarantee the callback runs at exactly 1000ms?
No — it guarantees a minimum delay of about 1000ms. The callback can run later if the call stack is still busy when that time is reached.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Part of the study guide
Related series