All articles

The JavaScript event loop, explained by predicting output

Microtasks, macrotasks and why setTimeout(fn, 0) does not run immediately — worked through with code you can reason about rather than a diagram.

· 3 min read

Most explanations of the event loop start with a diagram of a call stack, a queue and a spinning arrow. Diagrams are hard to reason from. Predicting output is not.

Start here. What does this print?

console.log('1')
setTimeout(() => console.log('2'), 0)
Promise.resolve().then(() => console.log('3'))
console.log('4')

The answer is 1 4 3 2. If that surprises you, the rest of this explains why.

The one rule that matters

JavaScript runs on a single thread. It executes all synchronous code first, without interruption. Only when the stack is empty does it look at queued work.

There are two queues, and they do not have equal priority:

  • The microtask queue — promise callbacks, queueMicrotask, await continuations
  • The macrotask queuesetTimeout, setInterval, I/O, UI events

After the synchronous code finishes, the engine drains the entire microtask queue, then takes one macrotask, then drains microtasks again, and so on.

That is the whole model. Apply it to the example:

  1. console.log('1') — synchronous, prints immediately
  2. setTimeout — callback goes to the macrotask queue
  3. .then — callback goes to the microtask queue
  4. console.log('4') — synchronous, prints immediately
  5. Stack empty. Drain microtasks: prints 3
  6. Take one macrotask: prints 2

Why setTimeout(fn, 0) is not immediate

The delay argument is a minimum, not a promise. setTimeout(fn, 0) means "queue this as a macrotask, eligible to run after at least 0 ms". It still waits for the synchronous code and the entire microtask queue.

Worse, if the synchronous code takes 500 ms, your 0 ms timer fires after 500 ms. The timer cannot interrupt running code — there is nothing to interrupt it with.

setTimeout(() => console.log('timer'), 0)
const end = Date.now() + 500
while (Date.now() < end) {}   // blocks for 500ms
console.log('loop done')
// "loop done" then "timer", ~500ms later

This is why a long synchronous computation freezes a page. Nothing else can run.

Microtasks can starve everything

Because the engine drains the microtask queue completely before taking a macrotask, a microtask that queues another microtask never lets go:

function loop() {
  Promise.resolve().then(loop)
}
loop()
// Timers never fire. The page never repaints.

The macrotask queue is never reached. This is a genuine way to hang a browser tab, and it is why "just use promises" is not universally good advice for scheduling repeated work — setTimeout yields between iterations, promises do not.

await is just microtasks

await splits a function in two. Everything before it runs synchronously; everything after becomes a microtask.

async function f() {
  console.log('1')
  await null
  console.log('2')
}
f()
console.log('3')
// 1, 3, 2

await null still yields, even though null is not a promise — the value gets wrapped and the continuation is queued. Awaiting a non-promise does not skip the suspension, which is a subtle source of ordering bugs.

Node adds one more queue

Node has a queue that runs at even higher priority than microtasks:

setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate'))
Promise.resolve().then(() => console.log('promise'))
process.nextTick(() => console.log('nextTick'))
// nextTick, promise, then timeout/immediate

process.nextTick drains before the promise microtask queue. Recursive nextTick calls starve the loop the same way recursive promises do — and in a server, that means it stops answering requests entirely.

The relative order of setTimeout(0) and setImmediate at the top level is genuinely non-deterministic and depends on how long the process took to start. Inside an I/O callback it is deterministic: setImmediate always runs first, because the check phase directly follows the poll phase.

Try it yourself

Predict the output before running:

console.log('A')

setTimeout(() => {
  console.log('B')
  Promise.resolve().then(() => console.log('C'))
}, 0)

Promise.resolve().then(() => {
  console.log('D')
  setTimeout(() => console.log('E'), 0)
})

console.log('F')

Answer: A F D B C E.

Synchronous first (A, F). Then microtasks: D runs and queues a timer. Then the first macrotask: B runs and queues a microtask, which drains immediately after that macrotask — so C comes before E, even though E's timer was created first.

Put it into practice

106 JavaScript questions, each with an explanation. Find out whether this actually stuck.

Take the JavaScript quiz

Related reading