skip to content

Microtask Starvation

A microtask that endlessly queues another microtask means timers, input handling, and rendering never get a turn, so the page hangs even though no single call is blocking. It is the sharpest test of whether you actually understood the drain-to-empty rule.

part ofJavaScriptoverview, primer and where to startread it →
on this pageshow

questions

4

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?

level: middleimportance: must knowfreq 55%

answer

  1. what does await actually suspend to
  2. not every suspension yields to timers
  3. continuation is a job, not a task
  4. already settled means nothing to wait for
  5. freeze identical to a synchronous loop

basics

~20 s

Awaiting 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 s

No. `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 lines
javascript
const 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

for a junior

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.

for a middle

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?

for a senior

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.

for a principal

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

context

open as a page

In a browser, a callback scheduled with queueMicrotask() calls queueMicrotask() on itself every time it runs. What happens to the page, and will a setTimeout callback that was already pending ever fire?

level: juniorimportance: should knowfreq 40%

basics

~20 s

The page freezes. The runtime drains the microtask queue until it is empty, and a microtask that re-enqueues itself never lets it empty, so the pending setTimeout callback, input handling and rendering never get a turn.

open as a page

A routine that drains a large work queue schedules its next step with queueMicrotask(), and the browser tab is frozen for as long as it runs. How would you restructure it so the UI stays responsive?

level: middleimportance: should knowfreq 45%

basics

~20 s

Reschedule each step onto the task queue instead of the microtask queue — setTimeout(fn, 0), a MessageChannel message, or scheduler.yield() where available — and process a batch per turn so the runtime can run timers, input and rendering between batches.

open as a page

After a release, one page in a web app becomes unresponsive: the tab pins a CPU core, no error appears in the console, and log lines inside setTimeout callbacks never print. How would you establish that a microtask loop is starving the event loop, and distinguish it from a plain blocking synchronous loop?

level: seniorimportance: should knowfreq 33%

basics

~20 s

Record a performance profile of the frozen tab. Microtask starvation shows thousands of short, repeating callbacks with a shallow stack inside one task, whereas a blocking loop shows a single long frame. Pausing the debugger lands in the self-rescheduling step.

open as a page