An async function in a browser tab runs `while (true) { await Promise.resolve(); }`. Does that await give the event loop a chance to run timers, dispatch input and repaint?
answer
- what does await actually suspend to
- not every suspension yields to timers
- continuation is a job, not a task
- already settled means nothing to wait for
- freeze identical to a synchronous loop
basics
~20 sAwaiting an already-resolved promise does not yield to timers or input. The continuation after await is queued as a microtask, so the microtask queue never empties and the tab freezes just like a synchronous while(true) loop.
solid answer
~40 sNo. `await` suspends the async function and schedules its continuation as a **microtask**, not a task. Since `Promise.resolve()` is already settled, that continuation is enqueued immediately, the microtask checkpoint picks it up, the loop body runs again and enqueues another one — so the queue is refilled as fast as it is drained and never reaches empty. The runtime therefore never returns to the task queue: timers do not fire, clicks are not dispatched, nothing is painted, and one core is pinned. The freeze is indistinguishable to a user from `while (true) {}`. What actually yields is awaiting a promise that will be settled *by a task* — a timer, an event, a network response — for example `await new Promise(r => setTimeout(r, 0))`, which ends the checkpoint and hands the loop back.
code
javascript · 12 linesconst yieldToLoop = () => new Promise(r => setTimeout(r, 0));
async function spinMicrotasks(n) {
for (let i = 0; i < n; i++) await Promise.resolve(); // never yields
}
async function spinTasks(n) {
for (let i = 0; i < n; i++) await yieldToLoop(); // yields every turn
}
setTimeout(() => console.log('timer ran'), 0);
spinMicrotasks(1e6).then(() => console.log('microtask loop done'));go deeper
Remember that await resumes through the promise queue, so awaiting something already resolved never lets a timer run. Recognise this loop as a freeze, not a pause.
Be ready to explain that the continuation after await is a promise reaction job, and to state the test that matters: what settles the awaited promise — a microtask, or a task?
Show how you would find this in a frozen page with no stack trace and no exception, and how you would rewrite a spin-wait into a promise the producer resolves once.
Own the guidance: unbounded loops over awaited work need an explicit yield budget, and 'await means non-blocking' is the misconception to design your team's async utilities against.
## What await really suspends to The mental model that causes this bug is "await pauses my function and lets the browser do its thing". Half of that is true: `await` does suspend the async function and return control to whatever called it. But *where the resumption is scheduled* is the part that matters. The remainder of the function is registered as a reaction on the awaited promise, and promise reactions run as microtasks. So resuming from `await` is a microtask, and microtasks all run inside the checkpoint that must drain to empty before the runtime looks at the task queue again. `Promise.resolve()` hands back a promise that is already fulfilled. There is nothing to wait for. The continuation is queued right away, run in the same checkpoint, and the loop body immediately queues the next one. ```js async function spin() { while (true) { await Promise.resolve(); // enqueues a microtask, never a task } } spin(); setTimeout(() => console.log('never printed'), 0); ``` The queue is refilled exactly as fast as it is drained, so it never empties, so no task ever runs again. ## Why it *looks* like it should work Three things conspire: 1. The code is not recursive and has no callback, so it does not resemble the classic self-scheduling bug. 2. It *does* suspend — put a `console.log` before and after the await and you can see the function genuinely yields control to its caller. 3. In small tests the loop is bounded, so the difference between "yields to microtasks" and "yields to the loop" never shows up. The distinction only bites when the loop is unbounded or very long: `await` yields to *other promise work*, never to the host's tasks. ## Which awaits do yield The question to ask about any `await` is: **what settles this promise?** - Settled synchronously, or by another microtask — `Promise.resolve()`, `await null`, `await 0`, a cached promise, a value already in memory: resumption is a microtask; the loop is not handed back. - Settled by a task — `new Promise(r => setTimeout(r, 0))`, a promise resolved from an event listener, a `fetch` response, a message from another context: the checkpoint must finish before that task can run, so awaiting it genuinely returns control to the loop. Note the second bullet is where the yield comes from — not from `await` itself. `await` is the same operation in both cases. Awaiting a non-promise is worth spelling out: `await 0` and `await null` do not skip the queue. The value is wrapped, and resumption is still a microtask, so `while (true) await 0;` freezes just as hard. ## The equivalent shapes All of these are the same bug wearing different clothes: ```js // 1. explicit recursion on a promise reaction function tick() { Promise.resolve().then(tick); } // 2. the await loop async function tick2() { while (true) await Promise.resolve(); } // 3. a "wait for a flag" spin that only a task could ever satisfy async function until(cond) { while (!cond()) await Promise.resolve(); } ``` The third is especially cruel: the flag is typically set by a timer or an event handler, and those are exactly the things the spin prevents from running. The condition is unsatisfiable by construction, so the wait is not merely slow — it can never end. ## Diagnosing it The stack gives you nothing, because each resumption returns before the next begins; there is no deep recursion and no long frame. What you see is a pinned core, an unresponsive tab, no exception and no log output from anything scheduled as a task. A useful confirmation is that a `setInterval` heartbeat started earlier stops printing the instant the loop begins. ## Fixing it If the loop must keep running, yield to a task periodically instead of on every iteration: ```js const yieldToLoop = () => new Promise(r => setTimeout(r, 0)); async function run(items) { let start = performance.now(); for (const item of items) { process(item); if (performance.now() - start > 5) { // budget per turn await yieldToLoop(); start = performance.now(); } } } ``` That keeps the throughput of a tight loop while giving the runtime a chance to run timers, dispatch input and paint between batches. If the wait is for a condition set elsewhere, do not spin at all — have the producer resolve a promise you await once.
- Does `await null` or `await 0` behave any differently from `await Promise.resolve()` here?No. A non-thenable operand is wrapped, and resumption is still scheduled as a microtask, so `while (true) await 0;` starves the loop exactly the same way. Nothing about the operand's type turns the suspension into a task boundary — only being settled by a task does that.
- So what exactly makes `await new Promise(r => setTimeout(r, 0))` different?The promise is settled by a timer callback, which is a task. The microtask checkpoint has to finish before that task can run, so the runtime gets its turn: timers fire, input is dispatched, the page can paint. The yield comes from what settles the promise, not from `await` itself.
- A colleague says the loop is fine because it awaits, so it is 'non-blocking'. How do you answer?Non-blocking means not occupying the thread synchronously, which this loop technically satisfies — and it still makes the page unusable. The property that matters is whether control returns to the task queue. Judge async code by that, not by whether the keyword `await` appears.
saying these in an interview costs you the question
- Says await always hands control back to the browser
- Thinks await Promise.resolve() is equivalent to setTimeout(0)
- Claims awaiting a plain value skips the microtask queue
- Calls the loop safe because it is 'non-blocking'
- Believes suspending an async function ends the microtask checkpoint