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,awaitcontinuations - The macrotask queue —
setTimeout,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:
console.log('1')— synchronous, prints immediatelysetTimeout— callback goes to the macrotask queue.then— callback goes to the microtask queueconsole.log('4')— synchronous, prints immediately- Stack empty. Drain microtasks: prints
3 - 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.