Lesson 1.2: Call Stack and Event Loop
Duration: 60 minutes
Learning Objectives
By the end of this lesson, you will be able to:
- Understand what the call stack is and how it works
- Explain what happens when functions call other functions
- Describe Web APIs and how they handle async operations
- Understand the callback queue and its role
- Explain how the event loop coordinates everything
The Call Stack
The call stack is a data structure that keeps track of where the program is in its execution. Think of it as a stack of plates - you add plates to the top and remove them from the top (Last In, First Out - LIFO).
function greet(): void {
console.log('Hello!');
}
greet();
How the Stack Works
When you call a function, it gets pushed onto the stack. When the function returns, it gets popped off.
Step 1: Program starts
Stack: [main()]
Step 2: greet() is called
Stack: [main(), greet()]
Step 3: console.log() is called
Stack: [main(), greet(), console.log()]
Step 4: console.log() completes and pops off
Stack: [main(), greet()]
Step 5: greet() completes and pops off
Stack: [main()]
Step 6: Program ends
Stack: []
Visualizing the Call Stack
Let us trace through a more complex example:
function multiply(a: number, b: number): number {
return a * b;
}
function square(n: number): number {
return multiply(n, n);
}
function printSquare(n: number): void {
const result = square(n);
console.log(result);
}
printSquare(4);
Stack Trace
1. printSquare(4) pushed
Stack: [main, printSquare]
2. square(4) pushed
Stack: [main, printSquare, square]
3. multiply(4, 4) pushed
Stack: [main, printSquare, square, multiply]
4. multiply returns 16, popped
Stack: [main, printSquare, square]
5. square returns 16, popped
Stack: [main, printSquare]
6. console.log(16) pushed
Stack: [main, printSquare, console.log]
7. console.log completes, popped
Stack: [main, printSquare]
8. printSquare completes, popped
Stack: [main]
9. main completes
Stack: []
Stack Overflow
What happens if the stack gets too deep?
function infinite(): void {
infinite(); // Calls itself forever
}
infinite();
// Error: Maximum call stack size exceeded
This is called a stack overflow - the stack runs out of space because functions keep being added without being removed.
// A common mistake: forgetting the base case in recursion
function countdown(n: number): void {
console.log(n);
countdown(n - 1); // Never stops!
}
// Fixed version:
function countdownFixed(n: number): void {
if (n < 0) return; // Base case - stops recursion
console.log(n);
countdownFixed(n - 1);
}
The Problem: Blocking Operations
Remember, JavaScript is single-threaded. If something takes a long time, it blocks everything:
// Imagine this function takes 5 seconds
function slowOperation(): void {
// Heavy computation...
const start = Date.now();
while (Date.now() - start < 5000) {
// Blocking for 5 seconds
}
console.log('Done!');
}
console.log('Start');
slowOperation(); // Everything freezes for 5 seconds!
console.log('End');
While slowOperation runs:
- The user cannot click buttons
- Animations freeze
- The page becomes unresponsive
This is terrible for user experience! We need a way to handle slow operations without blocking.
Web APIs / Node APIs
The solution is to delegate slow tasks to the runtime environment. The browser or Node.js provides APIs that can handle operations outside the main JavaScript thread.
console.log('Start');
// setTimeout is NOT JavaScript - it's a Web API
setTimeout(() => {
console.log('Timer done!');
}, 2000);
console.log('End');
Output:
Start
End
Timer done! // After 2 seconds
Wait - "End" printed before "Timer done!" even though it comes after in the code! This is where the event loop comes in.
Common Web APIs / Node APIs
| API | Purpose | Example |
|---|---|---|
setTimeout |
Delay execution | setTimeout(fn, 1000) |
setInterval |
Repeat execution | setInterval(fn, 1000) |
fetch |
HTTP requests | fetch(url) |
addEventListener |
Handle events | element.addEventListener('click', fn) |
fs.readFile (Node) |
Read files | fs.readFile(path, callback) |
The Callback Queue
When a Web API finishes its work, it does not immediately run its callback. Instead, the callback goes to the callback queue (also called the task queue).
console.log('First');
setTimeout(() => {
console.log('Timeout callback');
}, 0); // Even with 0ms delay!
console.log('Second');
Output:
First
Second
Timeout callback
Even with a 0ms delay, "Timeout callback" prints last. The callback must wait in the queue until the call stack is empty.
The Event Loop
The event loop is a simple process that constantly checks:
- Is the call stack empty?
- Is there anything in the callback queue?
- If yes to both, move the first callback from the queue to the stack
The Loop in Action
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
console.log('3');
Step by step:
1. console.log("1") → Stack: [log] → prints "1" → Stack: []
2. setTimeout called → Timer registered with Web API
Web API: [timer(0ms, callback)]
3. console.log("3") → Stack: [log] → prints "3" → Stack: []
4. Timer completes → callback goes to queue
Queue: [callback]
5. Event loop: Stack empty? Yes. Queue has item? Yes.
Move callback to stack
Stack: [callback]
6. callback executes → prints "2" → Stack: []
Output: 1, 3, 2
Multiple Callbacks
Let us trace a more complex example:
console.log('Start');
setTimeout(() => {
console.log('Timeout 1');
}, 1000);
setTimeout(() => {
console.log('Timeout 2');
}, 500);
setTimeout(() => {
console.log('Timeout 3');
}, 0);
console.log('End');
Execution Order
- "Start" prints (synchronous)
- Three timers are registered with Web API
- "End" prints (synchronous)
- Stack is now empty
- After 0ms: "Timeout 3" callback enters queue, executes
- After 500ms: "Timeout 2" callback enters queue, executes
- After 1000ms: "Timeout 1" callback enters queue, executes
Output:
Start
End
Timeout 3
Timeout 2
Timeout 1
A Real-World Analogy
Think of a restaurant:
- Call Stack = The chef (can only cook one dish at a time)
- Web APIs = Kitchen assistants (can prep ingredients while chef cooks)
- Callback Queue = Ready dishes waiting to be plated
- Event Loop = The chef checking if there are ready dishes to plate
Customer orders → Chef cooks → Assistant preps next order
→ Prepped order waits in queue
→ Chef finishes, picks up next from queue
The chef (main thread) never stops, but assistants (Web APIs) help by doing preparation work asynchronously.
The Microtask Queue
There is actually another queue with higher priority: the microtask queue. Promises use this queue.
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
Promise.resolve().then(() => {
console.log('3');
});
console.log('4');
Output:
1
4
3
2
Microtasks (Promises) run before regular callbacks (setTimeout). We will explore this more when we study Promises.
Queue Priority
Exercises
Exercise 1: Predict the Output
What will this code print?
console.log('A');
setTimeout(() => {
console.log('B');
}, 0);
console.log('C');
setTimeout(() => {
console.log('D');
}, 0);
console.log('E');
Solution
A
C
E
B
D
Explanation:
- "A" prints (sync)
- First setTimeout - callback goes to Web API
- "C" prints (sync)
- Second setTimeout - callback goes to Web API
- "E" prints (sync)
- Stack empty, callbacks execute in order: "B", "D"
Exercise 2: With Nested Callbacks
What is the output?
console.log('1');
setTimeout(() => {
console.log('2');
setTimeout(() => {
console.log('3');
}, 0);
console.log('4');
}, 0);
console.log('5');
Solution
1
5
2
4
3
Explanation:
- "1" prints
- First timeout registered
- "5" prints
- First callback runs: "2", nested timeout registered, "4"
- Nested callback runs: "3"
Exercise 3: Trace the Stack
Draw the call stack state for each step:
function a(): void {
console.log('a');
}
function b(): void {
console.log('b');
a();
}
b();
console.log('done');
Solution
Step 1: b() called
Stack: [main, b]
Step 2: console.log("b")
Stack: [main, b, log]
Output: "b"
Step 3: log completes
Stack: [main, b]
Step 4: a() called
Stack: [main, b, a]
Step 5: console.log("a")
Stack: [main, b, a, log]
Output: "a"
Step 6: log completes
Stack: [main, b, a]
Step 7: a() completes
Stack: [main, b]
Step 8: b() completes
Stack: [main]
Step 9: console.log("done")
Stack: [main, log]
Output: "done"
Step 10: log completes
Stack: [main]
Final output: b, a, done
Exercise 4: Fix the Bug
This code should print numbers 1-5 with a delay between each, but it prints "6" five times. Why? Fix it.
for (var i = 1; i <= 5; i++) {
setTimeout(() => {
console.log(i);
}, i * 1000);
}
Solution
The problem: var is function-scoped, so by the time the callbacks run, i is already 6.
Fix using let (block-scoped):
for (let i = 1; i <= 5; i++) {
setTimeout(() => {
console.log(i);
}, i * 1000);
}
Each iteration creates a new i in its own scope.
Key Takeaways
- Call Stack tracks function execution (LIFO - Last In, First Out)
- Stack Overflow occurs when too many functions are stacked
- Web APIs handle async operations outside the main thread
- Callback Queue holds callbacks waiting to be executed
- Event Loop moves callbacks to the stack when it is empty
- Microtask Queue (Promises) has higher priority than callback queue
Resources
| Resource | Type | Description |
|---|---|---|
| Loupe - Event Loop Visualizer | Tool | Interactive visualization |
| MDN: Concurrency Model | Documentation | Official event loop docs |
| What the heck is the event loop anyway? | Video | Philip Roberts' famous talk |
Next Lesson
Now that you understand the mechanics, let us explore the practical differences between synchronous and asynchronous code.