Here is a script mixing setTimeout, a promise chain and an await. Tell me the order the logs come out in, and why.
The engine finishes the current task, then drains the whole microtask queue before taking another task, so every promise callback and await resumption runs before a zero-delay timeout, in the order each was queued.
What the interviewer is scoring
- Does the candidate state that the microtask queue is drained to exhaustion between two tasks
- Whether an await resumption is placed correctly relative to a then callback queued before it
- That the second link of a promise chain is only queued once the first link settles
- Whether rendering is named as the thing microtask starvation delays
- Does the answer give a correct way to yield to the browser rather than awaiting an already-resolved promise
Answer
Two queues with different rules
There is one rule that decides almost every question of this shape. The event loop takes a task, runs it to completion, and then drains the microtask queue completely before it considers taking another task. Draining means running microtasks until the queue is empty, including any that were added by the microtasks it just ran.
Everything scheduled by setTimeout, by a postMessage, by a dispatched user input event or by network completion becomes a task. Everything scheduled by a settled promise, by an await resumption, by queueMicrotask and by a MutationObserver becomes a microtask. Two queues, and one of them is always emptied first.
console.log('script start');
setTimeout(() => console.log('timeout'), 0); // task
Promise.resolve()
.then(() => console.log('promise 1')) // microtask, queued now
.then(() => console.log('promise 2')); // queued only when promise 1 returns
queueMicrotask(() => console.log('microtask'));
(async () => { await null; console.log('after await'); })(); // body runs now, rest resumes as a microtask
console.log('script end');
The output is script start, script end, promise 1, microtask, after await, promise 2, timeout.
Walking it, because the reasoning is the answer
The synchronous run of the script is itself a task, so nothing asynchronous happens until it finishes. During it, the timeout is handed to the timer machinery. Promise.resolve() produces an already-fulfilled promise, so its first then callback is queued as a microtask immediately, while the second then cannot be queued yet because the promise it is attached to has not settled. queueMicrotask appends the next one. The async function is called like any other function and its body executes synchronously up to the await; at that point the remainder of the function is packaged as a continuation and scheduled as a microtask, and control returns to the caller. Then script end prints, and the task ends.
Now the drain begins, in queue order. promise 1 runs and returns undefined, which fulfils the promise that then produced, which queues promise 2 at the back of a queue that already holds two entries. So microtask and after await run before promise 2 does, which is the part most candidates get wrong. Only when nothing is left does the loop move on and pick up the timer task, so timeout is last no matter how small its delay.
Two details are worth volunteering. Ordering between a then callback and an await resumption is not special: they go into the same queue and run in the order they were added, which is why placing the async call last in the script puts its resumption after both earlier microtasks. And await on a plain value still costs a trip through the microtask queue, because the value is wrapped in a resolved promise first. await is not a synchronous shortcut when the value is already available.
Why this is not just a puzzle
The rule that the microtask queue is drained to exhaustion has a consequence with teeth: a microtask that queues another microtask keeps the queue non-empty, and the loop cannot advance. Not to the next task, not to timers, and not to the rendering steps. So a recursive walk expressed through promises freezes the page in a way the same recursion expressed through setTimeout does not, because the timer version returns control to the loop between iterations.
This is behind a class of bug that looks like a slow function and is really a scheduling mistake. A loop over a large collection that awaits something already resolved on each iteration, a validation pass that chains a promise per field, a data transform written with for await over an in-memory array: all of them run to completion before a single frame is painted, and the user sees a locked interface with no long task attributable to any one line.
The corollary is that awaiting a resolved promise is not a way to yield. To actually give the browser a chance to render you need to end the current task, which means scheduling a new one: historically a zero-delay timeout or a MessageChannel message, and where it is available the platform's own scheduler.yield, which resumes with better priority than a timer. Any of them work because they are tasks; nothing in the promise family does, because it is not.
It also settles the ordering question for rendering. Style, layout, paint and the requestAnimationFrame callbacks that run before them are part of the loop's rendering steps, which happen between tasks, so they come after the current task's microtasks have all been drained. A callback registered from inside a microtask does not get to run before the queue empties.
One task, then every microtask including the ones the microtasks add, and only then anything else. That single ordering rule predicts the log output, explains why promise recursion starves the frame, and tells you that awaiting a resolved promise is not yielding.
Likely follow-ups
- How would you process fifty thousand items without freezing the page, and what do you yield with?
- Where do requestAnimationFrame callbacks fall relative to the microtask queue?
- A test passes when you await a resolved promise twice and fails when you do it once. What does that tell you about the code under test?
- Why does a chain of zero-delay timeouts run more slowly than you would expect?
Related questions
- A promise rejects and nothing is awaiting it. What does Node do?hardAlso on promises and microtasks4 min
- Walk me through the phases of the Node event loop.mediumAlso on event-loop and microtasks4 min
- How is `this` determined in JavaScript, and why does a method lose it when you pass it as a callback?mediumAlso on javascript4 min
- What is a closure, and how does one end up leaking memory?mediumAlso on javascript4 min
- Your Node service has low CPU but latency spikes for every request at the same moment. What is happening?mediumAlso on event-loop4 min
- A request fails but your Express error-handling middleware never runs. How do you work out why?mediumAlso on async-await3 min
- Field data says your page has an INP of 400 ms. How do you find the cause and fix it?hardAlso on event-loop4 min
- Why does calling .Result or .Wait() on an async method deadlock, and how do you fix it properly?hardAlso on async-await5 min