Intermediate70 min read

JavaScript Runtime Execution Order: Event Loop Deep Dive

Master JavaScript's execution order: understand how synchronous code, Promises, and setTimeout execute. Learn the event loop, call stack, microtask queue, and macrotask queue.

Topics Covered:

Event LoopCall StackMicrotask QueueMacrotask QueueExecution OrderPromise Queue

Prerequisites:

  • Asynchronous JavaScript: Promises and async/await

Video Tutorial

Overview

Understanding JavaScript's execution order is crucial for debugging async code and writing performant applications. JavaScript executes code in a specific order: synchronous code first, then microtasks (Promises), then macrotasks (setTimeout, setInterval). This tutorial covers the event loop, call stack, microtask queue, macrotask queue, and how to predict execution order. You'll learn to solve complex execution order problems and understand why certain code runs when it does.

Understanding the Event Loop

The event loop is JavaScript's mechanism for handling asynchronous operations. It manages the execution of code, callbacks, and events. Event Loop Components: • Call Stack - Where synchronous code executes • Web APIs - Browser APIs (setTimeout, fetch, DOM) • Callback Queue (Macrotask Queue) - For setTimeout, setInterval • Microtask Queue - For Promises, queueMicrotask • Event Loop - Coordinates everything Execution Flow: 1. Execute all synchronous code from call stack 2. Execute all microtasks (Promises) 3. Execute one macrotask (setTimeout callback) 4. Repeat from step 2 Key Rule: Microtasks always run before macrotasks!

Code Example:
// Basic execution order
console.log("1 - Synchronous");

setTimeout(() => {
  console.log("2 - setTimeout (Macrotask)");
}, 0);

Promise.resolve().then(() => {
  console.log("3 - Promise (Microtask)");
});

console.log("4 - Synchronous");

// Output: 1, 4, 3, 2
// Explanation:
// 1. "1" - Synchronous code runs first
// 2. setTimeout - Moved to Web API, callback queued in Macrotask Queue
// 3. Promise - Moved to Microtask Queue
// 4. "4" - Synchronous code continues
// 5. "3" - Microtasks run (Promises have higher priority)
// 6. "2" - Macrotasks run after microtasks

// Visual representation:
// Call Stack: [main]
// 1. console.log("1") executes → "1" printed
// 2. setTimeout → Web API → Macrotask Queue
// 3. Promise.resolve().then() → Microtask Queue
// 4. console.log("4") executes → "4" printed
// 5. Call stack empty → Process Microtask Queue → "3" printed
// 6. Process Macrotask Queue → "2" printed

The event loop coordinates execution. Synchronous code runs first, then all microtasks, then one macrotask. This is why Promises run before setTimeout callbacks.

The Classic Interview Question

This is one of the most common JavaScript interview questions. Understanding it demonstrates deep knowledge of the event loop. Question: What is the execution order? Code: ```javascript console.log(1) new Promise(resolve => resolve()).then(() => console.log(2)) setTimeout(() => console.log(3), 0) console.log(4) ``` Answer: 1, 4, 2, 3 Why? • console.log(1) - Synchronous, runs immediately • Promise - Added to Microtask Queue • setTimeout - Added to Macrotask Queue • console.log(4) - Synchronous, runs immediately • Microtask Queue processed - console.log(2) runs • Macrotask Queue processed - console.log(3) runs

Code Example:
// The classic question
console.log(1);

new Promise((resolve) => {
  resolve();
}).then(() => {
  console.log(2);
});

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

console.log(4);

// Output: 1, 4, 2, 3

// Step-by-step execution:
// Step 1: console.log(1) → "1" printed (synchronous)
// Step 2: Promise created, resolve() called immediately
//         → .then() callback added to Microtask Queue
// Step 3: setTimeout → Web API → callback added to Macrotask Queue
// Step 4: console.log(4) → "4" printed (synchronous)
// Step 5: Call stack empty, process Microtask Queue
//         → console.log(2) → "2" printed
// Step 6: Process Macrotask Queue
//         → console.log(3) → "3" printed

// More complex example
console.log("Start");

setTimeout(() => console.log("Timeout 1"), 0);

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

Promise.resolve().then(() => {
  console.log("Promise 2");
});

setTimeout(() => console.log("Timeout 3"), 0);

console.log("End");

// Output: Start, End, Promise 1, Promise 2, Timeout 1, Timeout 3, Timeout 2
// Explanation:
// 1. "Start" - synchronous
// 2. setTimeout 1 → Macrotask Queue
// 3. Promise 1 → Microtask Queue
// 4. Promise 2 → Microtask Queue
// 5. setTimeout 3 → Macrotask Queue
// 6. "End" - synchronous
// 7. Process Microtasks: "Promise 1", "Promise 2"
//    - Promise 1 adds setTimeout 2 to Macrotask Queue
// 8. Process Macrotasks: "Timeout 1", "Timeout 3", "Timeout 2"

This classic question tests understanding of execution order. Remember: synchronous → all microtasks → one macrotask → repeat. Promises (microtasks) always run before setTimeout (macrotasks).

Microtask Queue vs Macrotask Queue

Understanding the difference between microtasks and macrotasks is crucial for predicting execution order. Microtask Queue (Higher Priority): • Promise.then() / Promise.catch() / Promise.finally() • queueMicrotask() • MutationObserver callbacks • Runs after current execution, before macrotasks • All microtasks run before next macrotask Macrotask Queue (Lower Priority): • setTimeout() • setInterval() • setImmediate() (Node.js) • I/O callbacks • UI rendering • Runs after all microtasks complete • One macrotask per event loop iteration

Code Example:
// Microtasks vs Macrotasks
console.log("1");

// Macrotask
setTimeout(() => console.log("Macrotask 1"), 0);

// Microtask
Promise.resolve().then(() => console.log("Microtask 1"));

// Macrotask
setTimeout(() => console.log("Macrotask 2"), 0);

// Microtask
Promise.resolve().then(() => {
  console.log("Microtask 2");
  // This microtask adds another microtask
  Promise.resolve().then(() => console.log("Microtask 3"));
});

console.log("2");

// Output: 1, 2, Microtask 1, Microtask 2, Microtask 3, Macrotask 1, Macrotask 2

// All microtasks run before any macrotask
// Even if microtasks add more microtasks, they all run first

// queueMicrotask() - explicit microtask
console.log("Start");

queueMicrotask(() => {
  console.log("Microtask from queueMicrotask");
});

Promise.resolve().then(() => {
  console.log("Microtask from Promise");
});

setTimeout(() => {
  console.log("Macrotask from setTimeout");
}, 0);

console.log("End");

// Output: Start, End, Microtask from queueMicrotask, Microtask from Promise, Macrotask from setTimeout
// Note: queueMicrotask runs before Promise.then() in some engines, but both are microtasks

// Nested microtasks
Promise.resolve().then(() => {
  console.log("1");
  Promise.resolve().then(() => {
    console.log("2");
    Promise.resolve().then(() => {
      console.log("3");
    });
  });
});

setTimeout(() => console.log("4"), 0);

// Output: 1, 2, 3, 4
// All nested microtasks run before macrotasks

Microtasks (Promises, queueMicrotask) have higher priority than macrotasks (setTimeout). All microtasks run before any macrotask, even if microtasks create more microtasks.

Async/Await and Execution Order

async/await is syntactic sugar over Promises, so it follows the same microtask rules. Async Function Behavior: • async functions return Promises • await pauses execution, doesn't block • Code after await runs as microtask • Follows Promise execution order Execution Flow: • Synchronous code before await runs immediately • await pauses, returns control • Remaining code runs as microtask • Other microtasks can run during await

Code Example:
// async/await execution order
console.log("1");

async function asyncFunc() {
  console.log("2");
  await Promise.resolve();
  console.log("3");
}

asyncFunc();

console.log("4");

// Output: 1, 2, 4, 3
// Explanation:
// 1. "1" - synchronous
// 2. asyncFunc() called, "2" printed (synchronous part)
// 3. await pauses, returns control
// 4. "4" - synchronous code continues
// 5. "3" - runs as microtask after await resolves

// More complex async/await
console.log("Start");

async function func1() {
  console.log("func1 start");
  await Promise.resolve();
  console.log("func1 end");
}

async function func2() {
  console.log("func2 start");
  await Promise.resolve();
  console.log("func2 end");
}

func1();
func2();

setTimeout(() => console.log("setTimeout"), 0);

console.log("End");

// Output: Start, func1 start, func2 start, End, func1 end, func2 end, setTimeout
// Explanation:
// 1. "Start" - synchronous
// 2. func1() called, "func1 start" printed
// 3. func2() called, "func2 start" printed
// 4. Both await, return control
// 5. "End" - synchronous
// 6. Microtasks: "func1 end", "func2 end"
// 7. Macrotask: "setTimeout"

// async/await with setTimeout
async function test() {
  console.log("1");
  await new Promise(resolve => {
    setTimeout(() => {
      console.log("2");
      resolve();
    }, 0);
  });
  console.log("3");
}

test();
console.log("4");

// Output: 1, 4, 2, 3
// Explanation:
// 1. "1" - synchronous part of async function
// 2. await with setTimeout → setTimeout is macrotask
// 3. "4" - synchronous code continues
// 4. setTimeout callback runs → "2" printed, Promise resolves
// 5. Code after await runs → "3" printed

async/await follows Promise execution order. Code before await runs synchronously, code after await runs as microtask. await doesn't block the event loop.

Common Execution Order Patterns

Recognizing common patterns helps you predict execution order quickly. Pattern 1: Promise Chain • Each .then() adds to microtask queue • All run before macrotasks Pattern 2: setTimeout in Promise • setTimeout callback is macrotask • Runs after all microtasks Pattern 3: Nested Async Operations • Inner operations queue before outer completes • Microtasks process in order Pattern 4: Promise.all() • All Promises resolve, then .then() runs • Single microtask for all results

Code Example:
// Pattern 1: Promise chain
Promise.resolve()
  .then(() => console.log("1"))
  .then(() => console.log("2"))
  .then(() => console.log("3"));

setTimeout(() => console.log("4"), 0);

// Output: 1, 2, 3, 4
// All .then() callbacks are microtasks

// Pattern 2: setTimeout in Promise
Promise.resolve().then(() => {
  console.log("1");
  setTimeout(() => console.log("2"), 0);
});

setTimeout(() => console.log("3"), 0);

// Output: 1, 3, 2
// Explanation:
// 1. Promise resolves → "1" printed (microtask)
// 2. setTimeout in Promise → added to Macrotask Queue
// 3. setTimeout outside → added to Macrotask Queue
// 4. Both macrotasks run: "3", "2" (order depends on queue)

// Pattern 3: Nested Promises
Promise.resolve().then(() => {
  console.log("1");
  Promise.resolve().then(() => {
    console.log("2");
    Promise.resolve().then(() => {
      console.log("3");
    });
  });
});

setTimeout(() => console.log("4"), 0);

// Output: 1, 2, 3, 4
// All nested microtasks run before macrotasks

// Pattern 4: Promise.all()
Promise.all([
  Promise.resolve().then(() => console.log("1")),
  Promise.resolve().then(() => console.log("2"))
]).then(() => console.log("3"));

setTimeout(() => console.log("4"), 0);

// Output: 1, 2, 3, 4
// Individual Promises run first, then Promise.all .then()

Recognize common patterns: Promise chains, setTimeout in Promises, nested async operations. Understanding these patterns helps predict execution order quickly.

Debugging Execution Order Issues

Common issues and how to debug them. Common Issues: • Code running in unexpected order • State updates not reflecting • Race conditions • Stale closures Debugging Techniques: • Add console.logs with labels • Use debugger statement • Check call stack in DevTools • Understand microtask vs macrotask • Use async/await for clearer flow

Code Example:
// Issue: State not updating as expected
function Component() {
  const [count, setCount] = useState(0);
  
  const handleClick = () => {
    setCount(count + 1);
    setCount(count + 1);
    console.log(count); // Still 0! (stale closure)
  };
  
  // Solution: Use functional updates
  const handleClickFixed = () => {
    setCount(c => c + 1);
    setCount(c => c + 1);
    // Both updates batched, count will be 2
  };
}

// Issue: Execution order confusion
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");

// Debug: Add labels
console.log("[SYNC] 1");
setTimeout(() => console.log("[MACRO] 2"), 0);
Promise.resolve().then(() => console.log("[MICRO] 3"));
console.log("[SYNC] 4");

// Use DevTools
// 1. Set breakpoints
// 2. Check Call Stack
// 3. Monitor Promise and setTimeout queues
// 4. Use Performance tab to see execution timeline

// Issue: Race condition
let data = null;

fetch("/api/data").then(res => {
  data = res.json();
});

// data might be null here!
console.log(data);

// Solution: Use async/await
async function loadData() {
  const data = await fetch("/api/data").then(r => r.json());
  console.log(data); // Guaranteed to have data
  return data;
}

Debug execution order issues by understanding microtasks vs macrotasks, using functional updates in React, and leveraging DevTools. async/await makes execution flow clearer.

Conclusion

Understanding JavaScript's execution order is essential for writing correct async code and debugging issues. You've learned about the event loop, microtask queue, macrotask queue, and how to predict execution order. Remember: synchronous code → all microtasks → one macrotask → repeat. This knowledge helps you write better React code, especially when working with useEffect, async operations, and state updates. Practice predicting execution order with different combinations of Promises, setTimeout, and async/await.