Two queues, not one
Lesson 4-1 showed the task queue, also called the macrotask queue, where setTimeout callbacks wait.
There is a second, higher-priority line called the microtask queue. Promise callbacks go there, meaning .then, .catch, .finally, and await continuations.
Here is the full event-loop rule.
- Run the script to completion.
- Drain the entire microtask queue, including microtasks queued by other microtasks.
- Run one macrotask, such as a timeout, then drain microtasks again, and repeat.
So a resolved promise's .then always beats a setTimeout(..., 0), no matter which order you wrote them in.
The split exists because promise reactions were designed to run as soon as possible after the current work. Coarser work like timers, clicks, and network callbacks should not cut in front of them.
Step 2 also implies a hazard. A microtask that queues another microtask forever never lets step 3 happen, so timers and rendering starve while the page stays technically responsive to nothing.
It matters in production. Code that assumes a 0 ms timeout runs before a .then is simply wrong, and ordering bugs like that are miserable to find.
A promise callback overtaking a timer
The timeout was written first and still runs last.
console.log("one"); setTimeout(() => console.log("two: timeout"), 0); Promise.resolve().then(() => console.log("three: promise")); console.log("four");
Output
one four three: promise two: timeout
The promise callback is a microtask, so it jumps ahead of the queued timeout.
Both synchronous logs come first, which is the unchanged part of the rule. Neither queue is touched while the script is still running.
Promise.resolve() produces an already-resolved promise, so its .then callback is queued immediately rather than waiting on anything.
Even a much longer synchronous tail would not change the order. The microtask waits for the script to finish and then goes ahead of the timer regardless.
Swapping the two lines changes nothing at all, which is the point worth testing yourself on. Priority is decided by queue, not by source order.
The true statement is that the microtask queue is fully drained before the next macrotask runs.
Microtasks are drained completely, including ones added while draining, before the loop touches the next macrotask.
That "including ones added while draining" clause is the part that distinguishes this queue from the macrotask queue, where each turn of the loop takes exactly one item.
Node and browsers both work this way, so the ordering is portable. Node adds its own wrinkles around process.nextTick and I/O phases, and the microtask-before-macrotask rule holds in both.
That behavior is also why an endless chain of microtasks can starve timers. A .then that always schedules another .then keeps step 2 running forever.
The practical version of the hazard is a recursive promise loop with no awaited I/O. It pins the CPU without ever blocking the stack in a way a profiler makes obvious.
await rides the same queue
await, which unit 6 makes your daily tool, is built directly on this machinery.
Everything after an await line is scheduled as a microtask. The function pauses, the rest of the script runs, and the continuation comes back through the microtask queue.
That is why the output below interleaves the way it does, and it is the single most useful fact about await's timing.
The pause is real but local. Only the async function is suspended, and the caller keeps running from the line after the call, which is why an async function returns a promise instead of a value.
Code before the first await runs synchronously, immediately, on the current stack. A common misreading is that calling an async function defers everything inside it.
Awaiting a non-promise still yields. await null and await 5 both schedule a microtask, so the ordering effect happens even when there is nothing to wait for.
Where an async function pauses
One await splits the function in two.
async function main() { console.log("A"); await null; console.log("C"); } main(); console.log("B");
Output
A B C
main runs synchronously up to the await and prints A.
The rest of main is parked as a microtask, so the script continues and prints B, and then the microtask resumes with C.
Nothing was actually awaited here, since null is not a promise. The yield happens anyway, which is why this is such a clean demonstration.
main() returned a promise that was still pending when B printed. Appending .then(() => console.log("D")) to the call would print D after C.
Reading async code as two halves per await is the habit that makes ordering puzzles easy. Everything up to the first await is synchronous, and every segment after one is a microtask.
The interview script
When asked why setTimeout(fn, 0) does not run immediately, answer in three beats.
- JavaScript is single-threaded, running code on a call stack.
- Timers queue a macrotask, which only runs when the stack is empty.
- Promise callbacks are microtasks, drained before the next macrotask, so
.thenbeatssetTimeout.
Say those three sentences and you have answered most event-loop questions.
The remaining part is applying them to a printed-order puzzle, like the one below, and the procedure is mechanical once the three beats are in place.
| Pass | What to list |
|---|---|
| 1 | Every synchronous line, top to bottom |
| 2 | Every microtask, in the order it was queued |
| 3 | One macrotask, then back to pass 2 |
Two additions are worth having ready if the interviewer pushes. Timer delays are minimums rather than guarantees, and a microtask that queues more microtasks can starve timers entirely.
The classic ordering puzzle
Five logs across both queues.
console.log("a"); Promise.resolve() .then(() => console.log("b")) .then(() => console.log("c")); setTimeout(() => console.log("d"), 0); console.log("e");
Output
a e b c d
The order is a e b c d.
The synchronous lines print first, giving a and e. Building the promise chain queues work without running any of it.
Then the microtask queue drains fully. b runs, and its .then queues c as another microtask, which runs right after in the same drain.
Only then does the macrotask d run, even though its delay expired long before c was queued.
The detail that catches people is that c was not queued when the drain started. It was created during the drain and still went ahead of the timer, which is step 2 of the rule doing its job.
Chaining a third .then would extend the same drain, so d stays last no matter how long the promise chain is.