Michael Ouroumis logoichael Ouroumis

Promises, Async/Await, and JavaScript Microtasks vs Macrotasks Explained

Abstract swirling beige, taupe, and navy shapes with bold black text reading ‘Promises, Async/Await Microtasks vs Macrotasks’

TL;DR: JavaScript promises and the async/await syntax schedule their callbacks as microtasks, which always run before the next macrotask (e.g., setTimeout). Understanding this ordering—and how to leverage microtasks vs. macrotasks—lets you write predictable, non-blocking, high-performance async code.


1. Promises & async/await: The Basics

A Promise represents the eventual result of an asynchronous operation:

const promise = new Promise((resolve, reject) => { // async work if (success) resolve('Done') else reject('Error') })
  • .then() / .catch(): attach callbacks on fulfillment or rejection.
  • async/await: syntactic sugar over promises for cleaner, synchronous-looking flow:
async function fetchData() { try { const data = await getJSON('/api/data') console.log(data) } catch (err) { console.error(err) } }

Under the hood, every await pauses execution, then resumes in a microtask once the promise settles.


2. Microtask Queue: Promise Callbacks & queueMicrotask

When a promise settles, its .then/.catch handler is enqueued as a microtask:

console.log('Start') Promise.resolve().then(() => console.log('Promise callback')) console.log('End') // Output: Start → End → Promise callback
  1. Start and End run on the call stack.
  2. Promise callback goes into the microtask queue—drained immediately after the stack clears, before any timers.

You can also enqueue microtasks manually:

queueMicrotask(() => { console.log('Custom microtask') })

3. Microtasks vs. Macrotasks

Queue TypeEnqueue ByRuns Before…
MicrotaskPromise.then, queueMicrotask, MutationObserverthe next macrotask
MacrotasksetTimeout, setInterval, I/O callbacks, UI eventsthe next event loop tick after microtasks
console.log('A') setTimeout(() => console.log('B'), 0) // macrotask Promise.resolve().then(() => console.log('C')) // microtask console.log('D') // A → D → C → B

4. How async/await Leverages Microtasks

Each await wraps the continuation in a microtask:

async function foo() { console.log('foo start') // 1 await null console.log('foo resumed') // 4 } console.log('script start') // 2 foo() console.log('script end') // 3 // Order: foo start → script start → script end → foo resumed
  • foo start logs immediately.
  • Awaited continuation (foo resumed) defers to a microtask, after the current stack.

5. Pitfalls & Best Practices

  • Uncaught rejections: Always add .catch() or wrap await in try/catch to avoid silent failures.
  • Starvation risk: Flooding with microtasks can delay rendering and macrotasks—use sparingly.
  • Chunk long work: Break CPU-heavy tasks into smaller macrotasks (setTimeout(…,0)) to keep the UI responsive.
  • Prefer async/await: Cleaner syntax and predictable microtask ordering.

Final Takeaways

  • Promises and async/await schedule work as microtasks.
  • Microtasks always drain before the next macrotask.
  • Properly balancing microtasks vs. macrotasks leads to smooth, predictable async flows.

Master this interplay to eliminate “why did this run first?” surprises and write rock-solid JavaScript. Happy coding!

Enjoyed this post? Share it: