The JavaScript Event Loop: Macrotasks vs Microtasks
Here's a quiz. What order do these log?
console.log('1')
setTimeout(() => console.log('2'), 0)
Promise.resolve().then(() => console.log('3'))
console.log('4')
If you said 1, 4, 3, 2 — you understand the event loop. If you expected 1, 2, 3, 4 or 1, 4, 2, 3, read on.
The Single Thread Reality
JavaScript has one call stack. Only one function runs at a time. But browsers and Node.js offload work to their host environment — network requests, timers, I/O — and when that work completes, a callback needs to run. The event loop is the scheduler that decides when and in what order those callbacks get to run on the single thread.
Three Queues, One Loop
The event loop works with three main parts:
- Call stack — where currently executing code lives
- Macrotask queue (also called the task queue) — for "bigger" callbacks
- Microtask queue — for "smaller", higher-priority callbacks
The loop's algorithm is:
- Run the current synchronous code to completion (empty the call stack)
- Drain the entire microtask queue (run all microtasks, including any that are enqueued during this step)
- Render updates if needed (browser only)
- Dequeue one macrotask and run it
- Back to step 2
The key asymmetry: all microtasks run before the next macrotask. The browser won't even render between microtasks.
What Goes Where
Macrotasks:
setTimeout/setIntervalcallbackssetImmediate(Node.js)- I/O callbacks
- UI events (click, input, keydown)
requestAnimationFrame(it's technically its own queue, runs before paint)
Microtasks:
Promise.then/.catch/.finallyhandlersqueueMicrotask()MutationObservercallbacksasync/await(which desugars to promises)
Walking Through the Quiz
console.log('1') // synchronous — runs immediately
setTimeout(() => console.log('2'), 0) // macrotask queued
Promise.resolve().then(() => console.log('3')) // microtask queued
console.log('4') // synchronous — runs immediately
- Call stack runs synchronous code: logs
1, queues the timeout callback as a macrotask, queues the promise callback as a microtask, logs4. Call stack is now empty. - Microtask queue is drained: logs
3. - One macrotask is dequeued: logs
2.
Result: 1, 4, 3, 2.
The Microtask Starvation Trap
Because all microtasks run before the next macrotask, an infinite chain of microtasks will starve the macrotask queue — and in a browser, block rendering entirely.
// ❌ Never yields to the browser — UI freezes
function loop() {
Promise.resolve().then(loop)
}
loop()
This is equivalent to an infinite synchronous loop. The call stack empties but the microtask queue never empties because loop keeps enqueuing itself.
Compare with:
// ✅ Yields to the browser between iterations
function loop() {
setTimeout(loop, 0)
}
loop()
Using setTimeout moves each iteration into the macrotask queue. The browser gets a chance to render between iterations.
async/await Is Just Promises
async function run() {
console.log('A')
await Promise.resolve()
console.log('B')
}
run()
console.log('C')
await suspends execution of run and queues the rest of the function as a microtask. The synchronous code after run() runs first:
Output: A, C, B.
This surprises developers who read await as "pause here and wait" — it does pause the function, but it doesn't block the thread. Execution returns to the call site, synchronous code continues, and the resumed function runs in a microtask.
Practical Implications
Use queueMicrotask() for deferred-but-urgent work. If you need to run something after the current synchronous code but before any timers or UI events, queueMicrotask() is more explicit than wrapping in Promise.resolve().then().
Don't use setTimeout(fn, 0) to "yield to the browser" for rendering. Use requestAnimationFrame if you want to synchronise with a paint, or scheduler.yield() (where available) for cooperative multitasking.
Long microtask chains delay paint. If you process a large data set with chained promises, the browser won't render until every .then() has run. Break work into macrotask chunks with setTimeout or use scheduler.postTask().
Conclusion
The event loop is what lets a single-threaded language feel concurrent. Macrotasks are the coarse-grained unit of work; microtasks are fine-grained callbacks that always run before the next macrotask and before the browser renders. Knowing this order eliminates an entire class of scheduling bugs and explains the execution order of anything involving promises, async/await, and timers.
