Skip to content

Tasks and microtasks

There is not one queue. There are two, and they are drained differently.

QueueSourcesDrained
Task (macrotask)setTimeout, setInterval, I/O, eventsOne per loop iteration
MicrotaskPromise callbacks, queueMicrotask, MutationObserverCompletely, after every task

The rule

After the stack empties, the event loop drains the entire microtask queue before taking the next task. Microtasks added while draining are also processed in the same pass.

console.log('script start')

setTimeout(() => console.log('timeout'), 0)

Promise.resolve()
  .then(() => console.log('promise 1'))
  .then(() => console.log('promise 2'))

console.log('script end')

Output:

script start
script end
promise 1
promise 2
timeout

Both promise callbacks run before the timeout, even though the timeout was scheduled first. They are microtasks; the timeout is a task.

await is a microtask boundary

await suspends the function and schedules the remainder as a promise continuation:

async function run() {
  console.log('a')
  await null // still a microtask, even awaiting a non-promise
  console.log('c')
}

run()
console.log('b')
// a, b, c

await null does not skip the queue. Everything after the await becomes a microtask, so the synchronous console.log('b') runs first.

Starving the loop

Because microtasks drain completely, a microtask that schedules another microtask forever prevents any task from ever running:

function spin() {
  Promise.resolve().then(spin)
}
spin()
// Timers never fire. Rendering never happens. The page is frozen —
// but the CPU profile looks like idle promise work, not a busy loop.

Rendering

In a browser, rendering is neither a task nor a microtask — it happens between tasks, at most once per frame. Since microtasks drain before rendering, a long microtask chain delays paint just as effectively as blocking code.