Predict the Output Order: the Event Loop Rule Is One Sentence

✓ numbers produced by the code on this page · Published 2026-08-20

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.

Order the output predict the order, then run it for real

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.

Three snippets. The second is the one that settles arguments — most people get it wrong the first time.

The baseline

javascript
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");
Output
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.

javascript
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");
Output
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

javascript
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");
Output
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.

What this page simplifies