Learning Objectives

  • Understand the components of the Event Loop
  • Learn how the call stack, task queue, and microtask queue work
  • Master the execution order of async code
  • Predict how setTimeout, Promises, and async/await execute

What is the Event Loop?

JavaScript is single-threaded, meaning it can only execute one piece of code at a time. Yet it handles asynchronous operations like network requests, timers, and user interactions without blocking. How? The Event Loop.

The Event Loop is a mechanism that coordinates the execution of code, handling events, and executing queued tasks. It's what makes JavaScript's non-blocking asynchronous behavior possible.

The Components

1. The Call Stack

The call stack is where JavaScript keeps track of function execution. When a function is called, it's pushed onto the stack. When it returns, it's popped off.

function first() {
    console.log('First');
}

function second() {
    first();
    console.log('Second');
}

second();

// Call stack progression:
// 1. second() pushed
// 2. first() pushed
// 3. first() pops (logs "First")
// 4. second() pops (logs "Second")

2. Web APIs / Browser APIs

When you call setTimeout, make a fetch request, or add an event listener, these operations are handled by the browser's Web APIs, not JavaScript itself. This is how JavaScript can be non-blocking.

console.log('Start');

setTimeout(() => {
    console.log('Timeout');
}, 0);

console.log('End');

// Output:
// Start
// End
// Timeout (even though delay is 0!)
Why does this happen? Even with a 0ms delay, setTimeout is handed off to the Web API, and its callback goes to the task queue, which only executes after the call stack is empty.

3. The Task Queue (Macrotask Queue)

When Web APIs complete (like a timer finishing or a network request returning), their callbacks are placed in the task queue. The Event Loop checks if the call stack is empty, then moves tasks from the queue to the stack.

Macrotasks include:

4. The Microtask Queue

Microtasks have higher priority than macrotasks. After each macrotask, the Event Loop processes all microtasks before moving to the next macrotask.

Microtasks include:

The Event Loop in Action

console.log('1: Sync');

setTimeout(() => {
    console.log('2: setTimeout');
}, 0);

Promise.resolve().then(() => {
    console.log('3: Promise');
});

console.log('4: Sync');

// Output:
// 1: Sync
// 4: Sync
// 3: Promise
// 2: setTimeout
Execution order:
  1. Synchronous code executes first: "1: Sync", "4: Sync"
  2. Call stack is empty, check microtask queue
  3. Promise callback executes: "3: Promise"
  4. Microtask queue empty, check task queue
  5. setTimeout callback executes: "2: setTimeout"

Complex Example: Mixing Everything

console.log('Start');

setTimeout(() => {
    console.log('Timeout 1');
    Promise.resolve().then(() => console.log('Promise in Timeout 1'));
}, 0);

Promise.resolve()
    .then(() => {
        console.log('Promise 1');
        setTimeout(() => console.log('Timeout in Promise 1'), 0);
    })
    .then(() => console.log('Promise 2'));

setTimeout(() => console.log('Timeout 2'), 0);

console.log('End');

// Output:
// Start
// End
// Promise 1
// Promise 2
// Timeout 1
// Promise in Timeout 1
// Timeout in Promise 1
// Timeout 2
Step-by-step breakdown:
  1. Sync code: "Start", "End"
  2. Microtasks: "Promise 1", "Promise 2" (all microtasks before next macrotask)
  3. Macrotask 1: "Timeout 1" executes
  4. Microtask from Timeout 1: "Promise in Timeout 1"
  5. Macrotask 2: "Timeout in Promise 1"
  6. Macrotask 3: "Timeout 2"

Async/Await and the Event Loop

async/await is syntactic sugar over Promises, so it follows the same microtask rules:

console.log('1');

async function asyncFunc() {
    console.log('2');
    await Promise.resolve();
    console.log('3'); // This is a microtask
}

asyncFunc();

Promise.resolve().then(() => console.log('4'));

console.log('5');

// Output:
// 1
// 2
// 5
// 3
// 4
Why this order? Everything after await is scheduled as a microtask, just like .then(). The microtasks execute in the order they were queued.

Common Pitfalls

Pitfall 1: Assuming setTimeout(fn, 0) executes immediately

It doesn't! It's queued as a macrotask and waits for the call stack and all microtasks to clear.

Pitfall 2: Infinite microtask loops
function recursiveMicrotask() {
    Promise.resolve().then(recursiveMicrotask);
}
recursiveMicrotask(); // Blocks the Event Loop!

This creates an infinite microtask queue, preventing macrotasks (like UI updates) from ever executing.

Pitfall 3: Blocking the main thread
// Bad: blocks for 3 seconds
const start = Date.now();
while (Date.now() - start < 3000) {}
console.log('Done'); // UI is frozen during this time

Long-running synchronous code blocks the Event Loop, freezing the UI.

Best Practices

1. Break up long tasks
// Instead of processing 10,000 items at once:
async function processItems(items) {
    for (let i = 0; i < items.length; i += 100) {
        const batch = items.slice(i, i + 100);
        await processBatch(batch);
        // Yields to Event Loop between batches
    }
}
2. Use microtasks for high-priority work
// High priority: use Promise or queueMicrotask
queueMicrotask(() => {
    // Executes before next macrotask
});

// Lower priority: use setTimeout
setTimeout(() => {
    // Executes after microtasks
}, 0);
3. Understand execution order for debugging

When debugging async issues, trace through:

  1. All synchronous code
  2. All microtasks (Promises, async/await)
  3. One macrotask (setTimeout, etc.)
  4. Repeat steps 2-3

Real-World Example: Debouncing

function debounce(func, delay) {
    let timeoutId;
    
    return function(...args) {
        // Clear previous timeout (macrotask)
        clearTimeout(timeoutId);
        
        // Schedule new timeout (macrotask)
        timeoutId = setTimeout(() => {
            func.apply(this, args);
        }, delay);
    };
}

// Usage: search input
const searchInput = document.querySelector('#search');
const debouncedSearch = debounce((query) => {
    console.log('Searching for:', query);
    // API call here
}, 300);

searchInput.addEventListener('input', (e) => {
    debouncedSearch(e.target.value);
});

Visualizing the Event Loop

Think of the Event Loop as a restaurant:

Key Takeaways