JavaScript Microtasks and Macrotasks: A Practical Guide to the Event Loop

This article explains JavaScript microtasks and macrotasks in practical terms, showing why `Promise.then()` usually runs before `setTimeout(..., 0)` and how the event loop prioritizes different kinds of asynchronous work.
JavaScript Microtasks vs Macrotasks: Understanding the Event Loop With a Real Example
If you've ever seen Promise.then() run before setTimeout(..., 0), you've already touched one of the most important parts of the JavaScript runtime: microtasks and macrotasks. These two queues are central to how the event loop keeps JavaScript responsive while still executing code in a predictable order.
Event Loop — A Visual Mental Model
The diagram below is a helpful way to understand how the call stack, microtask queue, and macrotask queue interact during execution:

The simple way to read it: synchronous code runs first on the call stack → the runtime drains the entire microtask queue → only then does it move to the next macrotask (timer callback, I/O, etc.).
What Are Microtasks?
Microtasks are high-priority tasks that run right after the call stack becomes empty, before the event loop moves on to the next larger task. Common sources of microtasks:
Promise.then/Promise.catch/Promise.finallyqueueMicrotask()MutationObserver(in browsers)
Key detail: After a macrotask finishes, the runtime will drain the entire microtask queue before picking up the next macrotask. That means if one microtask schedules another microtask, the new one will also run before timers, I/O callbacks, or any other macrotasks get a turn.
What Are Macrotasks?
Macrotasks are the larger units of work handled by the event loop. They include:
setTimeout/setInterval- I/O callbacks
- UI events (click, scroll, ...)
- Other environment-driven events
The mental model is simple: each event loop turn = 1 macrotask → drain microtasks → next turn.
This is why setTimeout(fn, 0) does not mean "run immediately." It only means "queue this callback as soon as possible in the macrotask queue" — but the callback still has to wait for the current stack to finish and for all pending microtasks to be processed first.
The Classic Example
console.log('A');
setTimeout(() => {
console.log('B');
}, 0);
Promise.resolve().then(() => {
console.log('C');
});
console.log('D');
Output:
A
D
C
B
Why?
| Order | Log | Reason |
|---|---|---|
| 1 | A | Sync code, runs immediately on the call stack |
| 2 | D | Sync code, runs immediately on the call stack |
| 3 | C | Promise.then → microtask, drained before macrotasks |
| 4 | B | setTimeout → macrotask, runs after microtask queue is empty |
Why Does JavaScript Separate Them?
Splitting work into microtasks and macrotasks gives JavaScript a balance between responsiveness and predictability:
- Microtasks: Promise chains and small follow-up operations need to run as soon as the current work completes.
- Macrotasks: Timers and environment callbacks can wait until the next event loop turn.
This model also explains why asynchronous JavaScript feels concurrent even though the language is single-threaded at the execution level. The runtime isn't running everything at once — it's scheduling work according to event loop rules and queue priority.
Common Mistakes
1. Assuming setTimeout runs before Promise.then
Many people assume setTimeout(..., 0) runs before a promise callback. In reality, Promise callbacks are microtasks, so they always get processed first.
2. Creating too many microtasks
Because the runtime tries to empty the entire microtask queue before moving on, deeply nested Promise.then() chains or repeated queueMicrotask() calls can delay timers, I/O, or rendering work.
Quick Reference
Promise.then()→ microtasksetTimeout()→ macrotask- After each macrotask: drain all microtasks before continuing
When debugging execution order, start with one question: does this callback run immediately, go into the microtask queue, or go into the macrotask queue? Once that mental model clicks, most "weird" async JavaScript behavior becomes much easier to predict.




