Under The Hood
frontend javascript ES2017+ / HTML event loop

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 equivalent
React batches state updates within a task and flushes in microtasks; its concurrent scheduler uses MessageChannel macrotasks to yield to the browser.

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.

before → after
Naive order assumption
setTimeout(() => console.log('timer'), 0); // ❌ OLD: naive code expects timers to run in call order
Promise.resolve().then(() => console.log('promise')); // runs BEFORE the timer
QueueMicrotask-aware ordering
queueMicrotask(() => console.log('micro')); // ✅ NEW: explicit, predictable microtask
setTimeout(() => console.log('macro'), 0); // macrotask runs after the microtask queue drains

Interview 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.

Under The Hood — a multi-depth technical interview hub.

Third-party licenses Press ⌘ K to search.