Event Loop Deep Dive
Understanding the event loop is crucial for mastering asynchronous JavaScript. The event loop is the mechanism that allows JavaScript to perform non-blocking operations despite being single-threaded. In this lesson, we'll explore how it works and how to leverage this knowledge for better code.
The JavaScript Runtime Model
JavaScript runs in a single-threaded environment with several key components:
JavaScript Runtime Components:
1. Call Stack
- Where function execution happens
- LIFO (Last In, First Out) structure
- One thing at a time
2. Web APIs / Browser APIs
- setTimeout, fetch, DOM events
- Provided by browser/Node.js
- Run outside the main thread
3. Callback Queue (Task Queue)
- Where callbacks from Web APIs wait
- FIFO (First In, First Out)
- Processed after call stack is empty
4. Microtask Queue (Job Queue)
- Higher priority than callback queue
- Promises and MutationObserver
- Processed before callback queue
5. Event Loop
- Continuously checks call stack and queues
- Moves tasks from queues to call stack
Key Concept: JavaScript is single-threaded, meaning it can only execute one piece of code at a time. The event loop is what makes asynchronous programming possible in this single-threaded environment.
How the Event Loop Works
Let's trace through an example to understand the execution order:
console.log("1: Start");
setTimeout(() => {
console.log("2: setTimeout");
}, 0);
Promise.resolve().then(() => {
console.log("3: Promise");
});
console.log("4: End");
// Output:
// 1: Start
// 4: End
// 3: Promise
// 2: setTimeout
// Why this order?
// 1. "Start" - synchronous, executed immediately
// 2. setTimeout callback goes to callback queue
// 3. Promise callback goes to microtask queue
// 4. "End" - synchronous, executed immediately
// 5. Call stack is empty, event loop checks queues
// 6. Microtask queue has higher priority
// 7. "Promise" executes (from microtask queue)
// 8. "setTimeout" executes (from callback queue)
Call Stack Visualization
Understanding the call stack helps you debug and reason about code execution:
function first() {
console.log("First function");
second();
console.log("First function end");
}
function second() {
console.log("Second function");
third();
console.log("Second function end");
}
function third() {
console.log("Third function");
}
first();
// Call Stack Progression:
// Step 1: [first]
// Step 2: [first, second]
// Step 3: [first, second, third]
// Step 4: [first, second] (third completes)
// Step 5: [first] (second completes)
// Step 6: [] (first completes)
// Output:
// First function
// Second function
// Third function
// Second function end
// First function end
Microtask Queue vs Callback Queue
The priority difference between these queues is crucial for understanding execution order:
console.log("Script start");
setTimeout(() => {
console.log("setTimeout 1");
}, 0);
Promise.resolve()
.then(() => {
console.log("Promise 1");
})
.then(() => {
console.log("Promise 2");
});
setTimeout(() => {
console.log("setTimeout 2");
}, 0);
Promise.resolve().then(() => {
console.log("Promise 3");
});
console.log("Script end");
// Output:
// Script start
// Script end
// Promise 1
// Promise 3
// Promise 2
// setTimeout 1
// setTimeout 2
// Execution Order:
// 1. Synchronous code runs first (Script start, Script end)
// 2. Microtask queue processes completely (all Promises)
// 3. Callback queue processes (all setTimeouts)
Priority Rule: Microtasks (Promises) always run before macrotasks (setTimeout, setInterval). ALL microtasks in the queue are processed before moving to the next macrotask.
setTimeout and setInterval Behavior
Understanding timer behavior in the event loop:
// setTimeout minimum delay
console.log("Start");
setTimeout(() => {
console.log("Timeout");
}, 0);
// Even with 0ms delay, setTimeout goes to callback queue
// It will run after all synchronous code and microtasks
// Output:
// Start
// Timeout
// setInterval gotcha
let count = 0;
const intervalId = setInterval(() => {
console.log("Interval:", ++count);
// Long-running task
const start = Date.now();
while (Date.now() - start < 2000) {} // Block for 2 seconds
if (count === 3) {
clearInterval(intervalId);
}
}, 1000);
// If interval takes longer than delay, callbacks stack up!
// This can cause performance issues
// Better approach: Use setTimeout recursively
function recursiveTimeout(count = 0) {
console.log("Recursive:", count);
if (count < 3) {
setTimeout(() => recursiveTimeout(count + 1), 1000);
}
}
recursiveTimeout();
// This ensures each callback finishes before next is scheduled
Promise Microtasks Deep Dive
Promises create microtasks that can lead to interesting execution patterns:
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve()
.then(() => console.log("3"))
.then(() => console.log("4"))
.then(() => {
console.log("5");
setTimeout(() => console.log("6"), 0);
})
.then(() => console.log("7"));
Promise.resolve().then(() => {
console.log("8");
Promise.resolve()
.then(() => console.log("9"))
.then(() => console.log("10"));
});
setTimeout(() => console.log("11"), 0);
console.log("12");
// Output:
// 1, 12, 3, 8, 4, 9, 5, 10, 7, 2, 11, 6
// Why this order?
// - Sync: 1, 12
// - First microtask batch: 3, 8, 4, 9, 5, 10, 7
// - Macrotasks: 2, 11, 6
Warning: Creating infinite microtasks will starve the callback queue! The event loop will never move to macrotasks if microtasks keep adding more microtasks.
RequestAnimationFrame
requestAnimationFrame has special timing in the event loop:
console.log("Start");
setTimeout(() => {
console.log("setTimeout");
}, 0);
requestAnimationFrame(() => {
console.log("requestAnimationFrame");
});
Promise.resolve().then(() => {
console.log("Promise");
});
console.log("End");
// Typical Output (browser):
// Start
// End
// Promise
// requestAnimationFrame
// setTimeout
// Execution order:
// 1. Synchronous code
// 2. Microtasks (Promises)
// 3. requestAnimationFrame (before next paint)
// 4. Macrotasks (setTimeout)
// requestAnimationFrame is ideal for animations
// It runs before the browser repaints
function animate() {
// Update animation state
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
Performance Implications
Understanding the event loop helps you write more performant code:
// BAD: Blocking the event loop
function heavyComputation() {
const start = Date.now();
while (Date.now() - start < 5000) {
// Blocks for 5 seconds
// UI freezes, no events can be processed
}
}
// GOOD: Break work into chunks
async function heavyComputationAsync() {
for (let i = 0; i < 1000; i++) {
// Do some work
processChunk(i);
// Give event loop a chance to process other events
if (i % 100 === 0) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
}
// BETTER: Use Web Workers for CPU-intensive tasks
const worker = new Worker("worker.js");
worker.postMessage({ task: "heavy-computation", data: largeDataset });
worker.onmessage = (event) => {
console.log("Result from worker:", event.data);
};
// Worker runs in separate thread, doesn't block main thread
Debugging Async Code
Tools and techniques for debugging event loop issues:
// 1. Use console.trace() to see call stack
function debugFunction() {
console.trace("Current call stack");
}
setTimeout(() => {
debugFunction();
}, 0);
// 2. Use performance.now() to measure timing
console.time("operation");
await someAsyncOperation();
console.timeEnd("operation");
// 3. Visualize microtask vs macrotask
function logWithType(message, type) {
console.log(`[${type}] ${message}`);
}
logWithType("Sync code", "SYNC");
setTimeout(() => {
logWithType("setTimeout", "MACRO");
}, 0);
Promise.resolve().then(() => {
logWithType("Promise", "MICRO");
});
queueMicrotask(() => {
logWithType("queueMicrotask", "MICRO");
});
// 4. Use browser DevTools Performance tab
// Record performance profile to see task timing
// 5. Check for long tasks (tasks > 50ms)
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn(`Long task detected: ${entry.duration}ms`);
}
});
observer.observe({ entryTypes: ["longtask"] });
Common Event Loop Pitfalls
// Pitfall 1: Assuming setTimeout(fn, 0) runs immediately
console.log("1");
setTimeout(() => console.log("2"), 0);
console.log("3");
// Output: 1, 3, 2 (not 1, 2, 3)
// Pitfall 2: Infinite microtask loop (starves macrotasks)
function infiniteMicrotasks() {
Promise.resolve().then(() => {
console.log("Microtask");
infiniteMicrotasks(); // Creates infinite microtasks
});
}
// infiniteMicrotasks(); // Don't run this! UI will freeze
// Pitfall 3: Mixing sync and async in loops
const urls = ["/api/1", "/api/2", "/api/3"];
// WRONG: forEach doesn't wait for async
urls.forEach(async (url) => {
const data = await fetch(url);
console.log(data); // All start at once
});
// CORRECT: Use for...of
for (const url of urls) {
const data = await fetch(url);
console.log(data); // Wait for each
}
// Pitfall 4: Not understanding Promise.resolve timing
console.log("1");
Promise.resolve().then(() => {
console.log("2");
});
console.log("3");
// Output: 1, 3, 2 (Promise callback is async)
Practice Exercise:
Task: Predict the output order of this complex event loop scenario:
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve()
.then(() => {
console.log("C");
setTimeout(() => console.log("D"), 0);
})
.then(() => console.log("E"));
setTimeout(() => {
console.log("F");
Promise.resolve().then(() => console.log("G"));
}, 0);
Promise.resolve().then(() => console.log("H"));
console.log("I");
// What's the output order?
Solution:
// Output: A, I, C, H, E, B, F, G, D
// Explanation:
// 1. Synchronous: A, I
// 2. Microtask queue (first batch): C, H, E
// - C executes, schedules setTimeout D
// - H executes
// - E executes (chained .then)
// 3. Callback queue: B, F
// - B executes (first setTimeout)
// - F executes (second setTimeout)
// 4. Microtask created by F: G
// 5. Callback queue: D (scheduled by C)
// Step-by-step:
// Sync: A, I
// Micro batch 1: C (schedules D in macro), H, E
// Macro: B
// Macro: F (schedules G in micro)
// Micro batch 2: G
// Macro: D
Best Practices
// 1. Don't block the event loop
// Use async/await or Web Workers for heavy computation
// 2. Understand microtask vs macrotask priority
// Promises run before setTimeout
// 3. Break long tasks into chunks
async function processLargeArray(items) {
for (let i = 0; i < items.length; i++) {
processItem(items[i]);
// Let event loop breathe every 100 items
if (i % 100 === 0) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
}
// 4. Use requestAnimationFrame for animations
function animate() {
updateAnimation();
requestAnimationFrame(animate);
}
// 5. Avoid nested setTimeout in loops
// Use recursive setTimeout instead
// 6. Monitor long tasks in production
const longTaskObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
console.warn(`Long task: ${entry.duration}ms`);
}
}
});
longTaskObserver.observe({ entryTypes: ["longtask"] });
Summary
In this lesson, you learned:
- JavaScript runtime has call stack, queues, and event loop
- Event loop enables async in single-threaded environment
- Microtask queue (Promises) has higher priority than callback queue
- setTimeout and setInterval are macrotasks in callback queue
- requestAnimationFrame runs before browser paint
- Blocking code freezes UI and prevents event processing
- Break heavy tasks into chunks to keep UI responsive
- Understanding execution order helps debug async code
Congratulations! You've completed Module 3: Asynchronous JavaScript! You now have a deep understanding of Promises, async/await, Fetch API, JSON handling, advanced async patterns, and the event loop. In the next module, we'll explore ES6+ data structures like Sets, Maps, and Symbols!