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

  1. Key words in plain English
  2. The problem, stated precisely
  3. The wake-up call sheet — an analogy
  4. The naive attempt — and why it fails
  5. Tracing it, step by step
  6. Fix 1 — swap var for let
  7. Fix 2 — wrap it in a closure (IIFE)
  8. Fix 3 — pass i as a function parameter
  9. Fix 4 — pre-bind the argument
  10. All four, side by side
  11. A note on setTimeout's timing
  12. See it live — DevTools
  13. Common misconceptions
  14. 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).
  • setTimeout is 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 setTimeout is 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. let is block scoped and var is function scoped (Lesson 9).
  • Binding is the link between a variable name and its storage. With let in 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 of f that always gets 5 as its first argument. The first value (null here) sets this, and console.log does 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

One shared slip vs. five separate slips

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

naive.js
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, 6

This 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.

loop runs & ends
i = 6 · 5 timers set
cb 1 fires
reads i → 6
cb 2 fires
reads i → 6
cb 3 fires
reads i → 6
cb 4 fires
reads i → 6
cb 5 fires
reads i → 6
0s
1s
2s
3s
4s
5s
Everything the loop does happens in the first millisecond. Every callback runs at least a full second after the loop — and i — are already finished. · timeline

Tracing It, Step By Step

step 1 / 11
Loop running · callback scheduled
Iteration 1 — setTimeout(…, 1000) schedules callback 1

The arrow function is created and handed to setTimeout. It closes over the loop's single i — currently 1. setTimeout returns immediately; nothing waits.

naive.jsclock ~0 ms
1for (var i = 1; i <= 5; i++) {
2 setTimeout(() => {
3 console.log(i);
4 }, i * 1000);
5}
6// 6, 6, 6, 6, 6
(console — nothing yet)
bindings the callbacks can see · 1
function / global · i1
pending timers · fire after
callback 11s→ the one shared i (read later)
Step through it. The loop finishes in a millisecond with i at 6; a second later the first callback looks at the only i there is. · interactive
  1. The loop runs to completion immediately

    setTimeout doesn'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 five setTimeout calls happen within a few milliseconds of each other.

  2. i keeps incrementing, in the same shared spot

    By the time the loop's condition i <= 5 finally fails, i has been incremented one last time — to 6 — and the loop exits.

  3. 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, shared i variable — the same one the loop itself was using.

  4. A second (or more) later, each callback finally runs

    By now i is 6 and 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

fix1-let.js
for (let i = 1; i <= 5; i++) {
  setTimeout(() => {
    console.log(i);
  }, i * 1000);
}
// 1, 2, 3, 4, 5 — one per second

Covered 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.

step 1 / 11
Loop running · callback scheduled
Iteration 1 — setTimeout(…, 1000) schedules callback 1

let gives this iteration its own block binding, i = 1. The callback closes over that one.

fix1-let.jsclock ~0 ms
1for (let i = 1; i <= 5; i++) {
2 setTimeout(() => {
3 console.log(i);
4 }, i * 1000);
5}
6// 1, 2, 3, 4, 5
(console — nothing yet)
bindings the callbacks can see · 1
iteration 1 block · i1
pending timers · fire after
callback 11s→ iteration 1's own i
Step through it. Five iterations create five i bindings; each callback reads its own when it fires. · interactive

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:

fix2-iife.js
for (var i = 1; i <= 5; i++) {
  (function (capturedI) {
    setTimeout(() => {
      console.log(capturedI);
    }, capturedI * 1000);
  })(i);
}
// 1, 2, 3, 4, 5

The 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.

step 1 / 11
Loop running · callback scheduled
Iteration 1 — setTimeout(…, 1000) schedules callback 1

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.

fix2-iife.jsclock ~0 ms
1for (var i = 1; i <= 5; i++) {
2 (function (capturedI) {
3 setTimeout(() => {
4 console.log(capturedI);
5 }, capturedI * 1000);
6 })(i);
7}
8// 1, 2, 3, 4, 5
(console — nothing yet)
bindings the callbacks can see · 2
function / global · i1
IIFE call 1 · capturedI1
pending timers · fire after
callback 11s→ IIFE call 1's capturedI
Step through it. The shared var i still ends at 6 — but every callback closes over its own IIFE call's capturedI instead. · interactive

Fix 3 — Pass i As A Function Parameter

fix3-helper.js
function scheduleLog(n) {
  setTimeout(() => {
    console.log(n);
  }, n * 1000);
}

for (var i = 1; i <= 5; i++) {
  scheduleLog(i);
}
// 1, 2, 3, 4, 5

Same 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.

step 1 / 11
Loop running · callback scheduled
Iteration 1 — setTimeout(…, 1000) schedules callback 1

scheduleLog(1) is an ordinary call: a fresh Variable Environment with its own parameter n = 1. The callback created inside closes over that n.

fix3-helper.jsclock ~0 ms
1function scheduleLog(n) {
2 setTimeout(() => {
3 console.log(n);
4 }, n * 1000);
5}
6
7for (var i = 1; i <= 5; i++) {
8 scheduleLog(i);
9}
10// 1, 2, 3, 4, 5
(console — nothing yet)
bindings the callbacks can see · 2
function / global · i1
scheduleLog call 1 · n1
pending timers · fire after
callback 11s→ scheduleLog call 1's n
Step through it. Each scheduleLog(i) call is a fresh Variable Environment with its own n. · interactive

Fix 4 — Pre-Bind The Argument

fix4-bind.js
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.

step 1 / 11
Loop running · callback scheduled
Iteration 1 — setTimeout(…, 1000) schedules callback 1

.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.

fix4-bind.jsclock ~0 ms
1for (var i = 1; i <= 5; i++) {
2 setTimeout(
3 console.log.bind(null, i),
4 i * 1000
5 );
6}
7// 1, 2, 3, 4, 5
(console — nothing yet)
bindings the callbacks can see · 1
function / global · i1
pending timers · fire after
callback 11sargument baked in: 1 (no lookup later)
Step through it. No new scope at all: the value is read once at bind time and carried inside the function. · interactive

All Four, Side By Side

FixWhat actually creates the new scopeStill uses var?
1. letA fresh block-scoped binding per loop iterationNo
2. IIFEA new function call, invoked immediately, per iterationYes
3. Helper functionA new function call (not immediately invoked) per iterationYes
4. .bind()No new scope — the value is baked into the function directlyYes

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

  1. 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 of i at the moment each setTimeout is called, so they are still 1, 2, 3, 4 and 5 seconds.)

  2. Set a breakpoint inside the arrow function

    On the console.log(i) line, then re-run.

  3. Check the Closure section each time it pauses

    All five pauses will show the exact same i, already at 6, in the Global scope (it would be a Closure entry named after the function if the loop were inside a function).

  4. Repeat with the let version

    This time each pause shows a different i (1, then 2, and so on) in its own Closure entry — a fresh i per 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