skip to content

Inside a long-running loop, does adding `await Promise.resolve()` on every iteration give the event loop a chance to run other queued work, the way `await new Promise(r => setTimeout(r, 0))` does?

level: middleimportance: should knowfreq 42%

answer

  1. two queues, only one of them yields
  2. await resumes as a microtask
  3. the checkpoint drains until empty
  4. same task, thread never released
  5. only a timer crosses the boundary

basics

~20 s

No. Awaiting an already-resolved promise only queues a microtask, and microtasks are drained inside the current task before the runtime moves on, so the thread is never released. Only crossing a task boundary, such as awaiting a zero-delay timer, actually yields.

solid answer

~40 s

They are not equivalent. `await Promise.resolve()` suspends the async function and schedules its continuation as a microtask; the microtask queue is drained at the checkpoint that ends the current task, so a loop that keeps awaiting resolved promises keeps refilling that queue and the task never finishes. Queued timers and input callbacks still wait. `await new Promise(r => setTimeout(r, 0))` is different in kind: the continuation is triggered by a timer, which is a *task*, so the current task has to complete first and everything already queued gets its turn. The rule of thumb is that microtasks order work *within* a task while tasks are the boundary *between* them — if the goal is to release the thread, only a real task boundary does it.

code

javascript · 13 lines
javascript
setTimeout(() => console.log('timer wanted to run'), 0);

(async () => {
  for (let i = 0; i < 200000; i++) {
    await Promise.resolve();   // microtask only
  }
  console.log('microtask loop finished');
})();

// microtask loop finished
// timer wanted to run
// The timer waits for the whole loop: awaiting a resolved
// promise never ends the task.

go deeper

for a junior

Know that promise callbacks and timer callbacks go into different queues, and that awaiting an already-resolved promise does not let a pending timer run.

for a middle

Explain the checkpoint rule — the microtask queue drains completely before the next task — and show the timer-based yield that actually crosses a task boundary.

for a senior

Recognise the production signature: an async-looking loop that still freezes, with queued callbacks firing in a late burst. Yield periodically on a time budget rather than per iteration.

for a principal

Own the API guidance: settle on one sanctioned yield helper and one budgeting policy for the codebase, because ad-hoc awaits scattered as "yields" leave latency problems that look fixed and are not.

## Two queues, two very different meanings A JavaScript runtime keeps at least two queues. The **task queue** holds whole units of work — a script evaluation, a timer callback, a dispatched event handler. The **microtask queue** holds continuations that must run before the runtime is allowed to move on — promise reactions and `queueMicrotask` callbacks. The processing rule is short: run one task to completion, then drain the microtask queue *entirely*, then and only then pick the next task. "Entirely" is the word that decides this question. If draining a microtask enqueues another microtask, that one is drained in the same pass. The checkpoint does not end until the queue is empty. ## Where `await` lands `await x` suspends the async function and schedules the remainder of it as a **microtask** once `x` settles. If `x` is already resolved — `Promise.resolve()`, or any non-promise value — the continuation is queued essentially immediately, so it runs at the very next checkpoint. ```js async function loop() { for (let i = 0; i < 1e6; i++) { doWork(i); await Promise.resolve(); // microtask hop, not a yield } console.log('done'); } setTimeout(() => console.log('timer'), 0); loop(); // 'done' prints before 'timer' ``` Every iteration hands control back to the runtime, but only as far as the microtask checkpoint — and because the loop immediately queues another microtask, the checkpoint keeps going. The task that started `loop()` cannot end until the entire million iterations are finished, so the timer scheduled before it still runs afterwards. The pending timer, and anything else in the task queue, is stuck behind a queue that never empties. That is microtask starvation, and it is exactly what a developer who reached for `await` as a "yield" was trying to avoid. ## Where the timer lands ```js const yieldToLoop = () => new Promise(resolve => setTimeout(resolve, 0)); async function loop() { for (let i = 0; i < 1e6; i++) { doWork(i); if (i % 1000 === 0) await yieldToLoop(); } } ``` Here the promise is *not* already resolved. It settles only when the timer callback runs, and the timer callback is a task. So the async function's current task ends, the microtask queue drains, and the runtime is genuinely free to pick the next task — which may well be a queued click handler or another timer scheduled earlier. The continuation resumes later, in a task of its own. Note the `if (i % 1000 === 0)`: crossing a task boundary is far more expensive than a microtask hop, because the host enforces a minimum delay on timers and each hop pays scheduling overhead. Yielding on *every* iteration would make the job orders of magnitude slower. Yield periodically, ideally on a time budget rather than an iteration count. ## The mental model to state in an interview - **Microtask** = "before anything else happens, finish this". It is an ordering tool: it guarantees your continuation runs before the next task, not that anyone else gets a turn. - **Task** = "I am done for now, take the thread". It is the yielding tool. So `await` is not a yield point in the scheduling sense. It is a *suspension* point in the language sense: it lets other code that is already running interleave with your continuation within the same task, which is how concurrency between multiple pending async operations works, but it never hands the thread back to the loop's task queue. ## The one case where `await` on a resolved value does something useful It is genuinely useful for ordering: deferring a read until every already-queued promise reaction has settled, or breaking a synchronous call chain so the caller's stack is gone before your code continues. That is a legitimate use — just not the same thing as releasing the thread. ## The diagnostic signature When someone has made this mistake, the symptom is distinctive: the UI is still frozen or the server still fails to answer other requests, even though the code is now full of `await`s and "looks async". Timers fire late in a burst after the loop finishes, in the order they were queued. Nothing appears to be blocking in the sense of a `while (true)` — the stack keeps unwinding and re-entering — yet no other task ever gets scheduled. The fix is always the same: make the awaited thing settle from a task, not from an already-resolved promise, and do it periodically rather than every iteration.

  • If awaiting a resolved promise does not yield, how do you yield from inside an async function?
    Await something that settles from a task rather than from an already-resolved promise — the standard form is `await new Promise(resolve => setTimeout(resolve, 0))`. That ends the current task, lets the runtime dispatch whatever is queued, and resumes your function in a later task. Do it periodically, on a time budget, because a task hop is far more expensive than a microtask hop.
  • So is `await` useless for anything other than real asynchrony?
    Not useless — just not a yield. Awaiting a resolved value is a legitimate ordering tool: it defers the rest of your function until every already-queued promise reaction has settled, and it unwinds the caller's stack before your continuation runs. Those are real guarantees. They simply operate inside the current task, so nothing else in the task queue gets a turn.
  • What does this look like in production when someone has confused the two?
    The symptom is a frozen UI or a server that stops answering other requests even though the code is full of `await`s. Nothing looks like a blocking loop — the stack unwinds and re-enters constantly — yet queued timers and handlers all fire late, in a burst, once the loop finishes. That burst pattern is the tell that the task never ended.

saying these in an interview costs you the question

  • Treats await as a general yield to the event loop
  • Says any await lets timers and input handlers run
  • Confuses the microtask checkpoint with the start of the next task
  • Yields on every single iteration and calls the result efficient
  • Thinks a microtask queue that keeps refilling still lets tasks through

context