Advanced JavaScript (ES6+)

Event Loop Deep Dive

13 min Lesson 20 of 40

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!