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?
answer
- wrong queue, not too much work
- yield to something a task settles
- one round trip per batch, not per item
- budget by elapsed milliseconds
- beware reentrancy between turns
basics
~20 sReschedule 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.
solid answer
~50 sThe problem is the queue you are scheduling on, not the amount of work. A `queueMicrotask` recursion keeps the microtask queue non-empty, so the checkpoint never finishes and no task ever runs. Move the continuation onto a **task**: `setTimeout(next, 0)` is the portable choice, a `MessageChannel` message is the low-latency alternative, and `scheduler.yield()` is a host API in some Chromium browsers that returns a promise resolved after yielding. Then batch: yielding once per item pays a full trip through the event loop per item and is far slower than the frozen version, so do work until a time budget — a few milliseconds measured with `performance.now()` — expires, then yield once and continue. That keeps throughput high while giving input, timers and painting a turn between batches. If the work is pure computation and touches no DOM, moving it off the main thread entirely is the more structural answer.
code
javascript · 15 linesconst BUDGET_MS = 5;
function drain(queue, process, done) {
const start = performance.now();
while (queue.length && performance.now() - start < BUDGET_MS) {
process(queue.shift());
}
if (queue.length) {
setTimeout(() => drain(queue, process, done), 0); // hand the loop back
} else {
done();
}
}
drain(Array.from({ length: 200000 }, (_, i) => i), () => {}, () => console.log('done'));go deeper
Know that queueMicrotask does not hand control back to the browser and that setTimeout does. Be able to state the fix as 'reschedule the next step as a timer'.
Explain both halves: move the continuation to the task queue, and batch against a time budget so you pay one loop round trip per batch instead of per item.
Bring up what breaks once the job spans many turns — reentrancy, cancellation, invariants that assumed run-to-completion — and when moving the work off the main thread beats slicing it.
Own the policy: a shared yielding utility with an explicit budget, feature-detected scheduling primitives, and a rule that any unbounded loop must document how it returns the thread.
## Diagnose before you fix The symptom is a frozen tab, but the cause is not "too much work" — a synchronous loop doing the same work would also freeze it, and both need the same class of fix. What is specific here is that the code *thought* it was yielding. `queueMicrotask(next)` does return control from the current callback, so it feels cooperative. It isn't: the microtask checkpoint drains to empty before the runtime looks at the task queue, and a recursion that enqueues one microtask per step keeps it permanently non-empty. Every consumer of the task queue — timers, input dispatch, rendering — is starved for the entire run. So the fix has two independent parts: **which queue you resume on**, and **how often you resume**. ## Part one: resume on a task ```js function drain(queue) { const item = queue.shift(); if (item === undefined) return; process(item); setTimeout(() => drain(queue), 0); // a task, not a microtask } ``` Ending the current job lets the checkpoint complete, and the runtime can then service pending tasks before running your next step. The usual options: - `setTimeout(fn, 0)` — works everywhere, and nesting it deeply means the host may enforce a minimum delay, which is one more reason not to yield per item. - A `MessageChannel`: `const {port1, port2} = new MessageChannel(); port1.onmessage = step; port2.postMessage(null);` — a task with less added delay than a timer, which is why libraries have historically used it. - `scheduler.yield()` — a host-provided API in some Chromium browsers that returns a promise you `await` to yield; feature-detect it and fall back, since it is not available everywhere and is not part of ECMAScript. Any of them turns "never yields" into "yields". None of them is fast enough to use once per item. ## Part two: batch against a time budget A round trip through the event loop is expensive relative to a small unit of work. Yielding per item can turn a 200 ms freeze into a job that takes minutes and still feels sluggish. Work to a budget instead: ```js const BUDGET_MS = 5; function drain(queue, done) { const start = performance.now(); while (queue.length && performance.now() - start < BUDGET_MS) { process(queue.shift()); } if (queue.length) setTimeout(() => drain(queue, done), 0); else done(); } ``` A budget of a few milliseconds per turn keeps the gap between turns short enough that input and animation feel live, while amortising the yield cost over many items. Budget by *elapsed time*, not by item count: item cost is rarely uniform, and a fixed count of 1000 cheap items and 1000 expensive ones behave nothing alike. The `async` spelling of the same structure reads better and behaves identically: ```js const yieldToLoop = () => new Promise(r => setTimeout(r, 0)); async function drain(queue) { let start = performance.now(); while (queue.length) { process(queue.shift()); if (performance.now() - start > 5) { await yieldToLoop(); start = performance.now(); } } } ``` Note that replacing `yieldToLoop()` with `Promise.resolve()` here reintroduces the original bug exactly — the shape looks cooperative while resuming on the microtask queue. ## What to watch for after the fix - **Reentrancy.** The loop now spans many turns, so other code runs in between and may mutate the same queue or the DOM you are updating. Take a snapshot, or make each step tolerant of concurrent changes. - **Cancellation.** Long multi-turn jobs need a way to stop when the user navigates away or changes the input; check a flag or a signal at each turn boundary rather than letting an abandoned job keep spending turns. - **Ordering.** Work that was previously atomic within one job is now interleaved with everything else, so any invariant that assumed run-to-completion across the whole job has to be re-established per batch. ## When yielding is the wrong fix If the work is pure computation that touches no DOM and no page state, slicing it across turns still spends the main thread's budget — you have made the page responsive but not fast. Moving that computation off the main thread entirely is the structural answer, and yielding is the pragmatic one for work that genuinely must run where the DOM lives. And if the loop was a spin-wait for a condition that some other callback sets, stop looping altogether: have the producer resolve a promise you await once, which is both correct and free.
- Why not just yield after every single item — isn't that maximally responsive?Each yield costs a full round trip through the event loop, and nested timers can be given a minimum delay by the host, so per-item yielding can stretch a sub-second job into minutes. Batch against a small time budget instead: the pause between turns stays short enough to feel live while the per-yield overhead is amortised over many items.
- Can you rely on scheduler.yield() instead of setTimeout?Not unconditionally. It is a host-provided scheduling API available in some Chromium browsers, not part of ECMAScript and not universally implemented, so feature-detect it and fall back to a timer or a MessageChannel message. Where it exists it yields with lower added latency and lets your continuation resume ahead of less important work.
- What new bug class does spreading the work across turns introduce?Reentrancy. The job no longer runs to completion in one turn, so other handlers run between batches and can mutate the collection you are draining or the DOM you are patching. Snapshot the input, re-check invariants at each turn boundary, and add a cancellation check so an abandoned job stops consuming turns.
saying these in an interview costs you the question
- Suggests queueMicrotask as the way to yield to the browser
- Yields once per item and calls the result optimised
- Batches by item count rather than elapsed time
- Thinks awaiting Promise.resolve() between items restores responsiveness
- Ignores that other code can now mutate state between batches