Episode 11: setTimeout + Closures Interview Question
This chapter dissects one of the most notorious and frequently asked JavaScript interview questions: the interplay between asynchronous timers (setTimeout) and variable scoping within closures. By tracing how the JavaScript runtime handles execution contexts, memory references, and the event loop, these notes explain why naive loop implementations fail and detail two distinct architectural fixes: modern block scoping using let and functional encapsulation closures using var.
1. The Core Mental Model: How setTimeout Behaves
A widespread misconception among developers is that JavaScript pauses or sleeps at the point where setTimeout is invoked. However, JavaScript is strictly single-threaded and non-blocking: it does not pause execution for a timer.
The Real-World Mental Hook
"Time, tide, and JavaScript wait for none."
When setTimeout is called:
- The JavaScript engine hands off the callback function along with its delay timer to the browser / runtime environment Web APIs.
- The runtime attaches a timer to the callback and stores it in memory.
- JavaScript immediately advances to execute the next line of synchronous code in the Call Stack.
- Once the specified timer duration elapses, the runtime pushes the callback function into the Callback Queue, which eventually enters the Call Stack via the Event Loop when the synchronous execution context clears.
2. Asynchronous Execution Walkthrough
Consider a basic example where a timer is combined with subsequent synchronous logging statements:
function x() {
var i = 1;
setTimeout(function () {
console.log(i);
}, 3000);
console.log("Namaste JavaScript");
}
x();
Execution Output
Namaste JavaScript
1
Step-by-Step Analysis
x()is pushed onto the Call Stack.- Variable
iis allocated in memory and initialized to1. setTimeoutregisters the anonymous callback function with a3000mstimer. This callback forms a closure over its lexical scope, binding to the reference ofi.- The JavaScript engine does not wait for 3000 milliseconds. It immediately moves to the next line and executes
console.log("Namaste JavaScript"). - Function
x()finishes its synchronous execution and pops off the Call Stack. - After 3000 milliseconds expire, the timer callback is scheduled, pushed onto the stack, and executed. It resolves
ivia its closure and logs1.
3. The Classic Interview Problem: Printing 1 to 5 with Delays
Interviewers often frame the challenge as follows:
The Challenge:
Write a program that prints the numbers1, 2, 3, 4, 5such that:
1appears after 1 second (1000ms),2appears after 2 seconds (2000ms),3appears after 3 seconds (3000ms),4appears after 4 seconds (4000ms),5appears after 5 seconds (5000ms).
4. The Naive Implementation and the "Prints 6" Bug
Most developers attempt to solve this challenge using a standard for loop with a var declaration, calculating the delay dynamically as i * 1000.
The Naive Code
function x() {
for (var i = 1; i <= 5; i++) {
setTimeout(function () {
console.log(i);
}, i * 1000);
}
console.log("Namaste JavaScript");
}
x();
Unexpected Output
Namaste JavaScript
6
6
6
6
6
Instead of printing 1, 2, 3, 4, 5, the engine prints Namaste JavaScript immediately, followed by the number 6 printed five times at one-second intervals.
5. Deconstructing the Bug: Reference vs. Value
The failure of the naive implementation stems from the combination of three mechanisms: asynchronous timing, closures retaining references (not values), and function scoping of var.
1. Variables Declared with var Are Function-Scoped
Because var is function-scoped (or globally scoped if not inside a function), there is only one single shared memory location allocated for i across all iterations of the loop inside x().
2. Closures Retain References, Not Values
When each setTimeout callback is registered, it forms a closure over the lexical environment of x(). Crucially:
Note: A closure does not take a snapshot or freeze the primitive value of a variable at the time of closure creation. It retains a live memory reference to the variable itself.
3. Loop Execution Outpaces Timer Expiration
The for loop executes synchronously and synchronously finishes in a fraction of a millisecond. The loop steps through:
i = 1-> Callback 1 queued for 1000msi = 2-> Callback 2 queued for 2000msi = 3-> Callback 3 queued for 3000msi = 4-> Callback 4 queued for 4000msi = 5-> Callback 5 queued for 5000msiincrements to6-> Condition6 <= 5fails -> Loop terminates.
By the time the first timer of 1000ms expires, the Call Stack has long completed the loop, and the single memory slot for i holds the value 6. When each of the five callbacks executes, they all resolve that same memory reference, printing 6 every time.
6. Solution 1: Block Scoping with let
The modern ECMAScript (ES6) approach to solve this issue is replacing var with let.
The Code
function x() {
for (let i = 1; i <= 5; i++) {
setTimeout(function () {
console.log(i);
}, i * 1000);
}
console.log("Namaste JavaScript");
}
x();
Correct Output
Namaste JavaScript
1
2
3
4
5
How It Works Behind the Scenes
Variables declared with let are block-scoped. In JavaScript, a for loop configured with a let binding re-binds the variable for every single iteration:
- In iteration 1, an isolated block scope is established where
i = 1. The callback forms a closure binding to this specific block's variablei. - In iteration 2, an entirely new block scope is created where
i = 2. The second callback closes over this second, separate variablei. - This process repeats for all 5 iterations, producing 5 distinct memory bindings for
i.
When each timer expires, its callback refers to its own private lexical copy of i, successfully logging 1, 2, 3, 4, 5.
7. Solution 2: The Interview Twist (Solving with var and Closures)
Experienced interviewers often follow up with a constraint:
"Fix this code without using
letorconst. You must usevar."
Because let cannot be used, block scoping is unavailable. The only mechanism left to create new scope boundaries around a var variable is function scope.
The Encapsulation Strategy
We can declare an auxiliary function (often called close or an IIFE) inside or alongside the loop, passing the current loop index as an argument.
Implementation with an Explicit Function Helper
function x() {
for (var i = 1; i <= 5; i++) {
function close(x) {
setTimeout(function () {
console.log(x);
}, x * 1000);
}
close(i);
}
console.log("Namaste JavaScript");
}
x();
Note: Alternatively, using parameter naming identical to
ifunctions identically due to shadowing, though naming itxorindexclarifies the isolation:JAVASCRIPTfunction close(i) { setTimeout(function () { console.log(i); }, i * 1000); } close(i);
Why This Fixes the Bug
- In JavaScript, arguments are passed into functions by value (for primitives).
- When
close(i)is invoked during iteration 1, the value1is copied into the local parameterx. - The
closeinvocation creates an entirely new functional execution context. - The callback inside
setTimeoutforms a closure not over the loop's outeri, but over theclosefunction's local parameterx. - Since
close()is called five distinct times, five independent functional execution contexts are created, each maintaining its own distinct variablexinitialized to1, 2, 3, 4,and5respectively.
8. Comparative Synthesis
9. Summary and Key Takeaways
- Non-Blocking Timer Execution:
setTimeoutdoes not pause or block synchronous execution; it offloads the callback and timer to the runtime environment and allows subsequent lines of code to run immediately. - Closures Store Variable References: A closure binds to the reference of an outer variable in memory, rather than capturing a static snapshot or copy of its value at the moment the closure is defined.
- The Cause of the
6 6 6 6 6Output: Usingvarcreates a single variable scoped to the outer function. Because the synchronous loop finishes before any timer triggers, the variable's value reaches6before any callback executes. - The Modern Solution (
let): Becauseletis block-scoped, the JavaScript runtime binds a distinct variable instance for each iteration of aforloop, providing each callback with its own separate lexical binding. - The Classic Workaround (Functional Closure): When restricted to
var, wrappingsetTimeoutinside an auxiliary function creates a new function execution context on each iteration. Passing the loop counter by value binds each callback to an independent local parameter. - Deep Understanding Eliminates Surprises: Rather than treating unexpected asynchronous behavior as a quirk, mastering lexical scoping, execution contexts, and closure memory models allows developers to predict and control execution deterministically.