Event Loop: Microtasks (Promises) vs Macrotasks (Timers, I/O)
- Last updated
- Prerequisites:
- Basics of Promises and async/await
- Understanding of callbacks and setTimeout
- event-loop
- microtasks
- macrotasks
- promises
- async
- timers
Read at your depth
The practical view
JavaScript has one main thread and one task queue, but jobs are split into two kinds. Macrotasks are setTimeout, setInterval, setImmediate, I/O callbacks, and message events — they are processed one at a time from the queue. Microtasks are Promise .then/.catch/finally handlers and queueMicrotask — after every macrotask (and after the current synchronous script), the engine drains the ENTIRE microtask queue before moving on. That is why a promise chain runs before the next setTimeout even if the timer was scheduled first: const p = Promise.resolve(); p.then(() => console.log('micro')); setTimeout(() => console.log('macro'), 0); logs 'micro' then 'macro'.
The same idea in other frameworks
React batches state updates within a task and flushes in microtasks; its concurrent scheduler uses MessageChannel macrotasks to yield to the browser.Zone.js patches both macrotasks and microtasks to know when to run change detection; the distinction determines when bindings refresh.Vue's scheduler queues watcher jobs as microtasks so multiple mutations in one tick flush once.Svelte's $effect callbacks are scheduled on microtasks, so effects run after the current synchronous flush.Legacy vs modern
Predicting execution order between promises and timers
The entire microtask queue is drained before the next macrotask runs, so promise continuations always beat same-tick timers regardless of scheduling order.
setTimeout(() => console.log('timer'), 0); // ❌ OLD: naive code expects timers to run in call order
Promise.resolve().then(() => console.log('promise')); // runs BEFORE the timerqueueMicrotask(() => console.log('micro')); // ✅ NEW: explicit, predictable microtask
setTimeout(() => console.log('macro'), 0); // macrotask runs after the microtask queue drainsInterview gotchas
Context
This is the canonical ordering question — interviewers want the two-queue model, not just the remembered output, because it underpins async reasoning in real apps.
The mechanical answer
There are two queues. setTimeout posts a macrotask; a resolved promise's reaction (the .then callback) is enqueued as a microtask. The event loop runs one macrotask, then performs a microtask checkpoint that drains the microtask queue to empty (including any microtasks enqueued by other microtasks). Since the promise's continuation is already in the microtask queue, it runs during the checkpoint immediately after the current script/task finishes, before the loop advances to pop the timer macrotask. So call order is irrelevant — the queue type decides priority. The only way the timer runs first is if it was already the currently-executing task's predecessor, which it cannot be here.
Trap
The naive explanation is 'setTimeout(0) means zero delay so it should run first'. Delay is a minimum, not a priority, and it is additionally clamped (e.g. nested timers clamp to 4ms). Another trap: thinking microtasks run after each and every callback within a task — they run only at checkpoints (after the task and at script evaluation), which is subtle but critical for predicting interleaving with I/O.
Context
This tests whether you grasp the drain-to-empty semantics and can reason about starvation — a real production concern in tight async loops.
The mechanical answer
Yes. After each macrotask, the engine drains the microtask queue completely. If every microtask enqueues another microtask (e.g. an async recursive loop that resolves and awaits itself, or a chain that keeps scheduling .then continuations), the microtask queue never empties, so the loop never advances to pop the next macrotask — timers and I/O callbacks starve. Browsers eventually surface 'slow network is detected' or 'page unresponsive' heuristics, but the starvation is real until the microtask chain yields. This is why tight recursion should periodically defer work to a macrotask (setTimeout/scheduler.yield) rather than pure promise self-resolution.
Trap
The trap answer is 'the event loop is fair, it will interleave'. Macrotasks and microtasks are not round-robin; microtasks have absolute priority and are drained to empty. Another trap: assuming an async function's await on itself uses the stack — it does not, each await is a fresh microtask, so the chain can grow unboundedly and monopolize the microtask queue.