Predict the Output Order: the Event Loop Rule Is One Sentence
The JavaScript event loop is the most-explained mechanism in the language and one of the least understood. Almost every explanation is a diagram: boxes labelled call stack, microtask queue, task queue, and arrows between them. Readers follow the arrows, agree, and then cannot say what a six-line program prints.
That is because reading a diagram and predicting an output are different skills, and only the second one is ever tested by real code.
The rule itself is one sentence. Finish the code you are running, then drain every microtask, then take one task from the timer queue — and repeat.
Everything below follows from it.
Order it yourself
Nothing is revealed until you commit. Afterwards, each line is labelled with the queue it came from.
JavaScript runs one piece of code at a time. Anything deferred goes into one of two queues, and the rule that decides what happens next is short: finish the code you are running, then drain every microtask, then take one task from the timer queue — and repeat.
That gives 1, 2, 3, 4, 5 for the first snippet: both synchronous lines, then both microtasks, then the timer. Promise
callbacks and queueMicrotask share one queue — promises are not a separate
priority level.
The second snippet is the one that settles arguments. Two timers, with a promise created inside
the first, prints sync D → promise C → timer A → promise queued inside timer A → timer B → timer queued inside promise C. The
microtask queue drains between the two timers, because the rule is "after every task",
not "after the synchronous part". Anyone holding the simpler model gets this wrong.
Click the output lines in the order you think they print. Nothing is revealed until you check — and the queue each line came from is shown afterwards, which is the part worth remembering.
The code
Click these in order
- Snippets solved
- 0/0
- Lines placed
- 0/0
- In the right slot
- ?
- Correct from the top
- ?
The same round, as text
Your order
Actual order
Queuesruns now: part of the current synchronous run · microtask: drains after the current task, before the next one · timer: a whole separate task, after all microtasks
Three snippets. The second is the one that settles arguments — most people get it wrong the first time.
The baseline
console.log("1 sync: runs immediately");
setTimeout(() => console.log("5 timer callback"), 0);
Promise.resolve().then(() => console.log("3 promise callback"));
queueMicrotask(() => console.log("4 queueMicrotask callback"));
console.log("2 sync: still the same tick");1 sync: runs immediately 2 sync: still the same tick 3 promise callback 4 queueMicrotask callback 5 timer callback
Two things in that output are worth stopping on.
setTimeout(..., 0) does not mean “now”. It means “queue this as a task”, and tasks are the last
thing considered. Zero is a minimum delay, not a request for immediacy.
Promises are not a priority level. The Promise.resolve().then callback and the
queueMicrotask callback go into the same queue and run in the order they were queued. There is no
separate promise lane — .then is simply a way of putting a function on the microtask queue.
The one that settles arguments
Most people carry a simplified model: synchronous code, then all the microtasks, then all the timers. That model is wrong, and here is where it breaks.
setTimeout(() => {
console.log("timer A");
Promise.resolve().then(() => console.log(" promise queued inside timer A"));
}, 0);
setTimeout(() => console.log("timer B"), 0);
Promise.resolve().then(() => {
console.log("promise C");
setTimeout(() => console.log(" timer queued inside promise C"), 0);
});
console.log("sync D");sync D promise C timer A promise queued inside timer A timer B timer queued inside promise C
Look at lines three to five. Timer A runs, the promise it created runs, and then timer B runs.
The microtask queue is drained after every task, not once after the synchronous part. Timer A is
a task; when it finishes, the loop empties the microtask queue before it will look at timer B. Under
the simplified model you would expect timer A, timer B, promise from A, and that is not what the
runtime does.
This is the practical consequence people actually hit: a microtask can starve the queue. A promise callback that schedules another promise callback, which schedules another, will keep the loop busy forever and no timer will ever run — because “drain every microtask” has no cut-off. A task that schedules another task cannot do this, since each task waits its turn.
await is not a pause
async function work() {
console.log("2 inside work, before await");
await null;
console.log("4 inside work, after await");
}
console.log("1 before calling work");
work();
console.log("3 after calling work");1 before calling work 2 inside work, before await 3 after calling work 4 inside work, after await
Calling an async function does not defer it. Everything up to the first await runs synchronously,
as part of the call — which is why line 2 appears before line 3, even though work() is never
awaited.
await splits the function in two. What follows becomes a microtask. And await null still defers,
even though there is nothing to wait for: await always yields control, which is a common source of
surprise when someone adds an await to a hot loop and the timing of everything around it changes.
What to take away
The rule is short enough to carry: finish the current code, drain all microtasks, take one task, repeat. Three consequences follow that cover most of what confuses people.
setTimeout(fn, 0)is the slowest way to defer, not the fastest.- A promise callback runs before any timer that was queued earlier.
- Anything after an
awaitis a microtask, so it runs before timers too.
What this page simplifies
- Node and the browser are not identical. These outputs are from Node. Browsers add rendering
between tasks, and Node has
process.nextTick, which runs even before the promise microtasks. The core ordering shown here is the same in both. - One task queue, in reality several. Browsers keep multiple task sources — timers, I/O, user input — and may choose between them. “The timer queue” is a simplification of a system with priorities this page does not cover.
- Timers are not precise.
setTimeout(fn, 0)is clamped by the runtime, and a timer set for 5ms may fire considerably later if a task is still running. Ordering is guaranteed; timing is not. - No rendering. In a browser, style and layout work happens between tasks, which is why a long synchronous loop freezes the page and why breaking work into tasks — not microtasks — is what keeps it responsive.