skip to content

A browser routine splits a long job into chunks and yields between them with `await new Promise(r => setTimeout(r, 0))`. Why can the remaining chunks be delayed by unrelated work, and what does scheduler.yield() change?

level: seniorimportance: should knowfreq 33%

answer

  1. queues are first-in-first-out per source
  2. who arrived while you were busy?
  3. the continuation loses its place
  4. resuming as a continuation, not a new task
  5. scheduler.yield inherits the priority

basics

~20 s

Yielding with a zero-delay timer puts your continuation at the back of the task queue, behind every task queued while you were running — so unrelated work runs between your chunks and the job stretches out. scheduler.yield() returns a promise whose continuation is prioritised ahead of newly queued tasks of the same priority.

solid answer

~50 s

Yielding is the right instinct: giving the browser the thread between chunks lets it dispatch input and paint. But `setTimeout(r, 0)` re-enters through the timer task source at the *back* of the queue, so anything queued while your chunk was running — other timers, message events, unrelated callbacks — runs before your next chunk does. Your job is now interleaved with everything else on the page, and on a busy page it can take far longer in wall-clock time than the work itself, which is a real problem when the user is waiting for the result. `scheduler.yield()` was designed for exactly this: `await scheduler.yield()` breaks the current task so the browser can handle input and rendering, but the continuation is treated as a continuation rather than a fresh task, so it resumes ahead of same-priority work queued in the meantime. You get the responsiveness of yielding without surrendering your place. It is not universally available, so wrap it and fall back to the timer.

code

javascript · 18 lines
javascript
const yieldToBrowser = () =>
  'scheduler' in globalThis && 'yield' in globalThis.scheduler
    ? globalThis.scheduler.yield()
    : new Promise((resolve) => setTimeout(resolve, 0));

async function run(items) {
  let held = performance.now();
  for (const item of items) {
    JSON.stringify(item);
    if (performance.now() - held > 50) {
      await yieldToBrowser();
      held = performance.now();
    }
  }
  console.log('finished', items.length);
}

run(Array.from({ length: 50000 }, (_, i) => ({ i })));

go deeper

for a junior

Know that yielding means ending the current task so the browser can handle clicks and repaint, and that awaiting a resolved promise does not do that.

for a middle

Be able to explain FIFO task queues: a setTimeout-based yield resumes behind everything queued while your chunk ran, which is why a chunked job can take far longer in wall-clock time than in CPU time.

for a senior

Expect to reach for scheduler.yield() and justify it precisely — a prioritised continuation rather than a fresh back-of-queue task — plus a time-budget yield policy and a feature-detected fallback.

for a principal

Own the shape of this across a codebase: one yielding helper, a priority convention so long jobs cannot promote themselves, and a clear line past which work belongs off the main thread entirely.

## The two costs of yielding Breaking a long job into chunks that hand the thread back is the standard way to keep a page responsive while doing a lot of work. But every yield has two costs, and engineers usually only account for the first. The obvious cost is **overhead** — each resumption is a fresh trip through the event loop. The subtle cost is **losing your place**. Task queues are first-in-first-out per task source, and the browser picks among sources. When you yield with `setTimeout(resolve, 0)`, your continuation joins the timer queue *behind* everything queued while your chunk was executing. On a quiet page that is nothing. On a live dashboard with sockets, timers, and observer callbacks, each of your chunks is separated from the next by an arbitrary amount of unrelated work. Total wall-clock time for the job becomes unbounded, even though its total CPU cost never changed — and that is the failure mode people misdiagnose as "chunking made it slower". ## Why the platform needed a new primitive Nothing in the older primitives fixes this. `setTimeout(0)` is back-of-queue by construction. `requestIdleCallback` is worse for a job the user is waiting on — it runs only in leftover time and can be starved indefinitely. `queueMicrotask` and `await Promise.resolve()` sit at the other extreme: they do not end the task at all, so they never let the browser handle input or paint. There was no way to say "pause, let the browser breathe, then give me the thread back before you start new work". ## scheduler.yield() ```js for (const chunk of chunks) { process(chunk); await scheduler.yield(); } ``` `scheduler.yield()` returns a promise. Awaiting it ends the current task, so the browser can run input handlers and rendering steps — the whole point of yielding. What differs is the resumption: the continuation is scheduled as a *continuation* of the yielding task, at the priority that task was running at, and it takes precedence over same-priority tasks that were enqueued while you were away. Your chunks stay close together, but a click in between is still serviced. This composes with `scheduler.postTask`: yield inside a task posted at `'background'` and you resume at background priority, so a genuinely deferrable job stays deferrable. Inheriting priority is what stops `yield()` from becoming a way to jump the queue. ## Availability and the fallback `scheduler.yield()` shipped in Chrome 129 and is not yet in every engine as of 2026, so production code detects it: ```js const yieldToBrowser = () => 'scheduler' in globalThis && 'yield' in globalThis.scheduler ? globalThis.scheduler.yield() : new Promise((resolve) => setTimeout(resolve, 0)); ``` The fallback is honest about what it gives up — back-of-queue resumption — while keeping the call sites identical, so the day support is universal you delete one branch. ## Yield on a schedule, not per item Orthogonal to which primitive you use: yield on a *time budget*, not on a fixed item count. Check elapsed time with `performance.now()` and yield once you have held the thread for something on the order of 50 ms. That keeps each task short enough not to delay input while amortising the yield overhead across many items — and it adapts automatically to slow devices, where a fixed item count is either far too long or wastefully short. Chromium additionally exposes `navigator.scheduling.isInputPending()`, which lets a loop yield only when input is actually waiting. It is Chromium-only and easy to misuse — pending input is not the only reason to yield, since rendering matters too — so treat it as a refinement on top of the time budget, never as the whole policy. ## What to say in an interview Name the mechanism: FIFO task queues mean a timer-based yield resumes behind whatever arrived while you ran; `scheduler.yield()` resumes as a prioritised continuation instead. Then add the two engineering caveats — inherit priority so deferrable work stays deferrable, and yield on a time budget rather than per item — and note the feature detection. Someone who has actually shipped this mentions the fallback without being asked.

  • Doesn't awaiting a resolved promise between chunks achieve the same thing more cheaply?
    No — it achieves something different. A promise continuation runs at the microtask checkpoint, inside the same task, so the browser never gets the thread back: no input dispatch, no rendering. The job stays exactly as blocking as it was, just written differently. Yielding means ending the task, which is what both the timer fallback and scheduler.yield() actually do.
  • How do you decide how often to yield?
    On elapsed time, not item count. Track `performance.now()` since the last yield and give the thread back once you have held it around 50 ms — the point where input starts to feel delayed. A fixed item count guesses wrong on every device: too coarse on a slow phone, wastefully fine on a desktop, and it drifts the moment the per-item cost changes.
  • If a job yields with scheduler.yield(), can it starve the rest of the page?
    No, because the continuation inherits the yielding task's priority and still comes after input and rendering. It jumps ahead of newly queued same-priority tasks, not ahead of the browser's own work. A job posted at background priority that yields resumes at background priority, so a long deferrable job cannot promote itself by yielding repeatedly.

saying these in an interview costs you the question

  • Thinks await Promise.resolve() yields to the browser
  • Assumes setTimeout(0) resumes immediately after the current task
  • Believes yielding reduces the total CPU cost of the job
  • Yields after a fixed number of items regardless of device
  • Uses scheduler.yield() with no feature detection or fallback

context