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

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
- Start and End run on the call stack.
- 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 Type | Enqueue By | Runs Before… |
---|---|---|
Microtask | Promise.then , queueMicrotask , MutationObserver | the next macrotask |
Macrotask | setTimeout , setInterval , I/O callbacks, UI events | the 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 wrapawait
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!