skip to content

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%

answer

  1. queue must reach empty
  2. checkpoint keeps draining new arrivals
  3. no task ever gets its turn
  4. shallow stack, pinned CPU, no error

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.

solid answer

~50 s

`queueMicrotask()` appends to the microtask queue, and the runtime drains that queue completely before it moves on to anything else — including microtasks enqueued *while* it is draining. A microtask that schedules itself again therefore never lets the queue reach empty, so the runtime never gets back to the next task. The already-pending `setTimeout` callback never fires, clicks are never dispatched, and the browser never repaints: the tab is frozen exactly as if you had written `while (true) {}`, while one CPU core sits at 100%. It is nastier to spot than a blocking loop, because each microtask returns normally — there is no single long-running function in the stack and no `RangeError` from stack overflow. The only fix is to stop the recursion or reschedule it onto a task-scheduling primitive such as `setTimeout`, so each step ends the checkpoint and hands control back.

code

javascript · 7 lines
javascript
function loop() {
  queueMicrotask(loop);
}

queueMicrotask(loop);
setTimeout(() => console.log('this never prints'), 0);
console.log('sync code still finishes first');

go deeper

for a junior

Know that microtasks all run before the next timer or event, and that a microtask which schedules another microtask forever freezes the page. Say plainly that the pending setTimeout callback never fires.

for a middle

Explain the mechanics: the checkpoint drains to empty including newly enqueued jobs, each callback returns so the stack never grows, and the fix is to reschedule onto a task with setTimeout or a similar primitive.

for a senior

Show you can recognise this from symptoms alone — a pinned core, no exception, no long frame — and separate it from a blocking synchronous loop before you start reading code.

for a principal

Frame it as an API-design hazard: any library that repeats work on promise continuations can starve its host. Argue for yielding to tasks with an explicit time budget as a house rule for long-running work.

## The rule that makes starvation possible JavaScript runs one job at a time to completion. Between jobs the runtime performs a *microtask checkpoint*: it takes microtasks — promise reaction jobs, `queueMicrotask()` callbacks — and runs them one after another. The defining detail is that the checkpoint drains the queue **to empty**, and anything enqueued during the drain joins the same pass rather than waiting for the next one. That is deliberate: it guarantees a promise chain settles fully before any timer, event, or rendering step can observe a half-finished state. The same guarantee is the trap. "Drain to empty" has no upper bound and no fairness escape hatch. If every microtask adds one more, the queue is never empty, so the drain never finishes. ## What the code looks like ```js function loop() { queueMicrotask(loop); // schedules the next one before returning } queueMicrotask(loop); setTimeout(() => console.log('never printed'), 0); ``` The timer callback is a task. Tasks only run after the checkpoint completes, and it never completes. The message is never printed, no matter how long you wait. The promise spelling of the same bug is more common in real code, because it does not look like recursion at all: ```js function poll() { Promise.resolve().then(poll); } poll(); ``` ## Why it does not overflow the stack A reasonable guess is that infinite recursion will die with `RangeError: Maximum call stack size exceeded`. It does not. `queueMicrotask(loop)` does not call `loop`; it *enqueues* it. The current invocation returns, the stack unwinds to nothing, and only then does the runtime pull the next microtask. The stack stays one frame deep forever. That is precisely why the failure has no useful stack trace to hand you — it is a scheduling failure, not a depth failure. ## What the user experiences One core is pinned at 100% while the tab does nothing observable. Scrolling, clicking and typing stop responding, because input handlers are dispatched as tasks. Animations and any pending DOM update never appear on screen, because painting happens between tasks, not between microtasks — the DOM may well have been mutated, but the user never sees it. After a while the browser offers to kill the unresponsive page. Nothing is logged, nothing throws, and the console itself becomes unusable because it needs the same thread. ## The same failure in other hosts This is not a browser quirk. Any host that implements the microtask checkpoint drains to empty, so a self-requeuing microtask starves timers and I/O callbacks in a server runtime too — the process burns a core and stops making progress. The mechanism belongs to the language; only the list of starved consumers differs by host. ## Where it comes from in practice Almost nobody writes the loop above deliberately. It appears as: - a "wait until ready" poll implemented with `Promise.resolve().then(check)` instead of a timer, where the condition is set by a task that can now never run — so the poll is unsatisfiable by construction; - a recursive retry whose failure path resolves synchronously, so every attempt costs one microtask and no elapsed time; - a work-queue drainer that pulls the next item with `await` on an already-settled promise, believing that `await` yields to the event loop. ## Handing the loop back The cure is to move the recursion onto the **task** queue so the checkpoint can finish. `setTimeout(loop, 0)` is the portable choice; a `MessageChannel` message is the classic low-latency alternative; `scheduler.yield()` exists in some Chromium browsers as a host-provided way to yield and resume. All three end the current job, letting the runtime run timers, dispatch input, and paint before your next step. Combine that with batching — do a few milliseconds' worth of work per turn rather than one item — so you pay one round trip through the loop per batch instead of per item. ```js function loop() { setTimeout(loop, 0); // now the tab stays responsive } loop(); ``` The distinction worth carrying away: microtasks are for finishing what you started, tasks are for starting again later. Anything that must repeat indefinitely belongs on a task.

  • Is there any queue length at which the engine gives up and lets a task run?
    No. The checkpoint is specified as "drain until empty", with no cap, no time budget and no fairness rule, so there is no length or duration at which the runtime interrupts the drain to service a task. That is why the recursion is fatal rather than merely slow.
  • Why is there no useful stack trace when this happens?
    Because each microtask returns before the next one starts. `queueMicrotask` enqueues a callback rather than calling it, so the stack unwinds to nothing between iterations and stays one frame deep. There is no deep recursion to overflow and no long-running frame to point at — only a scheduling loop the profiler has to reveal.
  • Does the DOM update if the starving code mutated it before looping?
    The mutation is applied to the DOM immediately, but the user never sees it. Painting happens between tasks, and the checkpoint never ends, so no rendering opportunity arrives. The document's state and what is on screen diverge until the loop stops or the page is killed.

saying these in an interview costs you the question

  • Says the engine caps microtasks and eventually runs the timer
  • Claims the recursion will throw a stack-overflow RangeError
  • Thinks microtasks run on a separate thread so the page stays live
  • Believes rendering can interleave between individual microtasks
  • Says only a synchronous while(true) can freeze a tab

context