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?
answer
- symptom is shared, shape is not
- does anything on the task queue still run
- profile stack depth tells you which
- short repeating callbacks, no task boundaries
- then bisect for self-rescheduling code
basics
~20 sRecord 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.
solid answer
~50 sBoth failures look identical from outside — pinned core, dead UI, nothing logged — so separate them by the *shape* of the work, not the symptom. Take a performance profile: the sampling profiler runs outside the frozen thread, so it still records. A blocking loop appears as one continuous frame sitting in a single function; microtask starvation appears as an unbroken run of very short, self-similar callbacks with a shallow stack and no task boundaries between them. Pausing in the debugger confirms it: you break inside a promise continuation or `queueMicrotask` callback rather than deep inside one function, and resuming lands you in the same place again. Corroborate with a heartbeat: a `setInterval` started before the freeze stops printing at the exact moment, proving the task queue — not just the current function — is blocked. Then bisect the release for code that reschedules on `queueMicrotask` or an already-settled promise.
code
javascript · 8 lines// Heartbeat probe: distinguishes 'task queue starved' from 'everything is late'.
let last = performance.now();
setInterval(() => {
const now = performance.now();
const lateness = Math.round(now - last - 250);
if (lateness > 50) console.warn('event loop late by', lateness, 'ms');
last = now;
}, 250);go deeper
Know that a starving microtask loop throws nothing and logs nothing, so the console will not tell you what happened. Learn to reach for a performance profile instead.
Explain the distinguishing evidence: a blocking loop is one long frame with a stable deep stack, while starvation is a run of very short repeating callbacks with a shallow stack and no task boundaries.
Show a diagnosis order — heartbeat to prove the task queue is dead, profile to establish the shape, debugger pause to confirm, then bisect the release — and name the code patterns you would grep for.
Own the prevention: responsiveness instrumentation shipped by default, a review rule that self-rescheduling loops declare which queue they resume on, and spin-waits treated as defects rather than style.
## Why the symptom alone does not identify the cause A pinned core plus a dead UI is the shared signature of several distinct failures on a single-threaded runtime: - a synchronous loop or a very expensive computation that never returns; - a microtask loop that keeps the checkpoint from ever finishing; - runaway synchronous work re-triggered by a handler, so each turn is short but they never stop. They demand different fixes, and only the third is even visible as separate tasks. Guessing wastes the one thing you have — the reproduction. ## Confirm the task queue is blocked, not just slow Start with the cheapest signal. Add a heartbeat before the suspect path: ```js let last = performance.now(); setInterval(() => { const now = performance.now(); console.log('heartbeat late by', Math.round(now - last - 250), 'ms'); last = now; }, 250); ``` If the heartbeat stops entirely at the moment the page freezes, the **task queue** is starved — no timer callback is being dispatched at all. If it keeps printing with growing lateness, you have contention rather than starvation, and the hunt is for something long but finite. This one line separates "nothing runs" from "everything is late", which is the first fork in the tree. ## Read the shape in a profile Record a performance profile across the freeze. The sampling profiler is not running on the blocked thread, so it still collects while the page is unresponsive. What you are reading for is shape: - **Blocking synchronous work** — one continuous frame. The stack is stable across samples, often deep, and the same function sits at the top for the whole span. There is exactly one task. - **Microtask starvation** — one task that never ends, containing an enormous run of very short, near-identical callbacks. The stack is *shallow*, because each microtask returns before the next begins, and it is the same shallow stack over and over. There are no gaps for rendering, no input events dispatched, no other tasks anywhere in the span. That shallow-and-repeating signature is the tell. It also explains why the failure produces no useful error: no frame is long, no recursion overflows the stack, nothing throws. ## Confirm in the debugger Pause execution during the freeze. In the blocking case you break somewhere inside the long-running function, and the call stack shows the path that called it. In the starvation case you break in a promise reaction or a `queueMicrotask` callback with almost nothing beneath it, and the debugger will typically show the async cause that scheduled it. Resume and pause again: landing repeatedly in the same short callback, with a fresh shallow stack each time, is confirmation. Stepping is also diagnostic. Stepping out of a starving microtask returns immediately — and the next microtask begins — rather than continuing inside a long function body. ## Find the offender in the diff Once the mechanism is established, the search is narrow. Look through the release for any code that schedules its own continuation without a task boundary: - `queueMicrotask` called from inside a `queueMicrotask` callback; - `Promise.resolve().then(sameFunction)` recursion; - `while (!cond) await Promise.resolve()` or similar spin-waits, especially where `cond` is set by a timer or an event handler — those can never terminate, because the spin blocks the very callback that would flip the flag; - a retry that resolves synchronously on failure, so every attempt costs a microtask and no elapsed time; - a library update that changed a scheduler from a timer to a microtask. A text search for `queueMicrotask` and for `.then(` inside the functions the profile named usually finishes the job in minutes once you know what you are looking for. ## Why a plain reproduction is often faster than tooling Because the freeze is deterministic, a bisect over the release — or over feature flags — costs a handful of reloads and points at a commit. Use tooling to establish the *class* of failure, and bisection to find the line. Trying to bisect without knowing the class leads teams to "optimise" a function that was never the problem. ## Prevent the recurrence Ship the heartbeat, or an equivalent responsiveness probe, as permanent instrumentation in development builds — starvation is trivially detectable the moment it appears and nearly invisible afterwards. Make it a review rule that any self-rescheduling loop names the queue it resumes on, and that unbounded loops yield to a task with an explicit time budget. And treat spin-waiting on a flag as a defect on sight: if another callback sets the condition, await a promise that callback resolves instead of polling for it.
- How can a profiler record anything at all if the page's thread is frozen?The sampling profiler does not run on that thread. It periodically captures the thread's stack from outside, so a thread stuck in a loop is sampled normally — you get a flame chart of exactly what it is stuck in. That is why profiling works when console logging, which needs the blocked thread, produces nothing.
- The heartbeat keeps printing but each interval is hundreds of milliseconds late. What does that rule out?It rules out starvation: tasks are still being dispatched, so the microtask checkpoint is completing. You are looking at long but finite work per turn — an expensive handler, or a burst of tasks each doing too much. That points at profiling individual long frames rather than hunting a self-rescheduling loop.
- Why does a spin-wait on a flag set by a timer never terminate rather than merely being slow?The spin keeps the microtask queue non-empty, and the timer callback that would set the flag is a task, so it can never be dispatched. The condition is unsatisfiable by construction. Waiting longer cannot help — the fix is to await a promise the producer resolves, not to poll.
saying these in an interview costs you the question
- Assumes any frozen tab means a long synchronous function
- Says profiling is impossible because the page is stuck
- Looks for an exception or stack trace that starvation never produces
- Concludes 'slow code' and starts optimising the wrong function
- Treats a silent heartbeat and a late heartbeat as the same signal