Back

How JavaScript Promises Work with the Event Loop

How JavaScript Promises Work with the Event Loop

JavaScript’s asynchronous behavior often puzzles developers. You write setTimeout(() => console.log('timeout'), 0) expecting immediate execution, yet a Promise resolves first. Understanding how JavaScript Promises interact with the Event Loop reveals why this happens and helps you write more predictable asynchronous code.

Key Takeaways

  • The event loop processes all microtasks before moving to the next macrotask
  • Promises and async/await use the microtask queue, gaining priority over setTimeout
  • Understanding the two-queue system helps predict JavaScript’s execution order
  • Recursive microtasks can starve the event loop and block macrotasks

The JavaScript Event Loop Foundation

JavaScript runs on a single thread, processing one operation at a time. The event loop enables asynchronous operations by coordinating between the call stack and two distinct queues: the microtask queue and the macrotask queue.

The call stack executes synchronous code immediately. When the stack empties, the event loop checks for pending tasks in a specific order:

  1. All microtasks execute first
  2. One macrotask executes
  3. The cycle repeats

This priority system explains why promises behave differently from setTimeout.

JavaScript Macrotask vs Microtask: The Critical Difference

Understanding macrotask vs microtask distinctions is essential for predicting code execution order.

Macrotasks include:

  • setTimeout
  • setInterval
  • I/O operations
  • UI rendering

Microtasks include:

  • Promise callbacks (.then, .catch, .finally)
  • queueMicrotask()
  • MutationObserver callbacks

The event loop processes all microtasks before moving to the next macrotask. This creates a priority system where promises always execute before timers.

Promises and the Microtask Queue in Action

Let’s examine how promises interact with the event loop through code:

console.log('1');

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

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

console.log('5');

Output: 1, 5, 3, 4, 2

Here’s the execution flow:

  1. Synchronous console.log('1') executes immediately
  2. setTimeout schedules callback to macrotask queue
  3. Promise callbacks queue as microtasks
  4. Synchronous console.log('5') executes
  5. Event loop processes all microtasks (3, 4)
  6. Event loop processes one macrotask (2)

The microtask queue empties completely before any macrotask runs, even with zero-delay timeouts.

Async/Await and the Event Loop Integration

Async/await behavior follows the same microtask rules. The await keyword pauses function execution and schedules the continuation as a microtask:

async function example() {
  console.log('1');
  await Promise.resolve();
  console.log('2');  // This becomes a microtask
}

example();
console.log('3');

// Output: 1, 3, 2

After await, the remaining function body joins the microtask queue. This explains why console.log('3') executes before console.log('2') despite appearing later in the code.

Common Pitfalls and Practical Patterns

Microtask Queue Starvation

Creating microtasks recursively can block the event loop:

function dangerousLoop() {
  Promise.resolve().then(dangerousLoop);
}
// Don't do this - blocks all macrotasks

Mixing Timers with Promises

When combining different async patterns, remember the execution priority:

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

fetch('/api/data')
  .then(response => response.json())
  .then(data => console.log('data'));

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

// Order: immediate → data (when ready) → timeout

Debugging Execution Order

Use this pattern to trace execution flow:

console.log('Sync start');

queueMicrotask(() => console.log('Microtask 1'));

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

Promise.resolve()
  .then(() => console.log('Microtask 2'))
  .then(() => console.log('Microtask 3'));

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

console.log('Sync end');

// Output: Sync start, Sync end, Microtask 1, Microtask 2, Microtask 3, Macrotask 1, Macrotask 2

Conclusion

The event loop’s two-queue system determines JavaScript’s asynchronous execution order. Promises and async/await utilize the microtask queue, gaining priority over setTimeout and other macrotasks. This knowledge transforms mysterious async behavior into predictable patterns, enabling you to write more reliable asynchronous JavaScript code.

Remember: synchronous code runs first, then all microtasks clear, then one macrotask executes. This cycle repeats, making JavaScript’s single thread handle complex asynchronous operations efficiently.

FAQs

Promise callbacks are microtasks while setTimeout creates macrotasks. The event loop always processes all microtasks before moving to the next macrotask, regardless of the timeout duration.

Yes, continuously creating promises or microtasks without allowing macrotasks to run can starve the event loop. This prevents UI updates and other macrotasks from executing.

Async/await is syntactic sugar for promises. The await keyword pauses execution and schedules the continuation as a microtask, following the same priority rules as promise callbacks.

Different patterns follow their queue rules. Promises and async/await use microtasks, while setTimeout and setInterval use macrotasks. Microtasks always execute first when the call stack is empty.

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Check our GitHub repo and join the thousands of developers in our community.

OpenReplay