JavaScript Scheduler API Table
| Piece | What it does | Field note |
|---|---|---|
await scheduler.yield() | Yield, priority intact | The modern chunking primitive - continues BEFORE other tasks |
vs setTimeout(chunk) | Priority tax | setTimeout drops you to the macrotask queue - input waits behind you |
isInputPending() | Check before yielding | Skip the yield when the user is mid-interaction |
scheduler.postTask(priority) | Schedule with priority | user-blocking / user-visible / background tiers |
Long task = 50ms | The frame budget | Everything above it blocks input - chunk under it |
Continuation semantics | Where you resume | yield resumes in the same task group - no priority demotion |
Chrome-first | The support gate | Fallback: the setTimeout chunking pattern (same shape, worse priority) |
Chunking pattern | The loop shape | for chunk: work(chunk); if (over budget) await yield() - the shared skeleton |
scheduler.yield() is the modern long-task fix: await it mid-loop and the browser pauses YOUR work to serve input and rendering first - then resumes you AT YOUR PRIORITY, before the macrotask queue. The old setTimeout-chunking worked but demoted you behind every input event.
Bottom line: the chunking skeleton is shared across eras - work a chunk, check the budget (long task = 50ms), yield when over - and isInputPending() skips the yield entirely when the user is mid-interaction. postTask's priority tiers (user-blocking, user-visible, background) schedule work around user work.
The honest part: scheduler.yield() is Chrome-first - the fallback is the setTimeout chunking pattern, the same loop shape with worse priority behavior. And yielding fixes INTERLEAVING; it does not make the work cheaper. A job that needs the CPU for 5 total seconds takes 5 seconds - yield decides who waits, not whether.
How to use
- Chunk the loop: for (const item of items) { work(item); if (performance.now() - start > 40) { await scheduler.yield(); start = performance.now(); } } - under the 50ms task budget.
- Skip yields mid-interaction: if (!navigator.scheduling?.isInputPending()) await scheduler.yield() - don't pause while the user is actively typing or dragging.
- Tier background work: scheduler.postTask(fn, { priority: 'background' }) - analytics and prefetch yield to everything user-visible.
Frequently asked questions
What problem does scheduler.yield() solve that setTimeout chunking did not?
Priority demotion. The chunking pattern (process a slice, then setTimeout(processNext, 0)) works - but setTimeout schedules you into the MACROTASK queue at default priority: while your chunks continue, every pending input event, rendering opportunity and other task interleaves, and your work stretches unpredictably under load. scheduler.yield() continues your work at its ORIGINAL priority - input and rendering jump ahead (that is the yield), but your continuation is not dumped behind unrelated macrotasks. The result: the same cooperative slicing with stable, predictable completion and no input latency spikes - the intent of chunking, without the tax.
What is the 50ms long-task budget and how do chunks relate to it?
It is the interaction ceiling. Research and the INP metric converge on: tasks longer than ~50ms make the page feel sluggish, because input events arriving mid-task wait for the task to finish before handlers run. So the chunking rule is budget-driven: check elapsed time every iteration or two, and yield before crossing ~40-50ms, leaving the rest of the frame for the browser. Chunks smaller than that are invisible to users; chunks larger become long tasks that show up in the performance panel and in INP. The performance API's longtask entries are the before-and-after proof: the same total work, restructured from one 900ms task into twenty 40ms tasks.
How does isInputPending change the chunking loop?
It makes yields CONDITIONAL. Yielding costs a little (the continuation lands in a later task), so yielding 500 times during a burst of typing is counterproductive - each yield queues behind the input events it was meant to prioritize. isInputPending() reports whether input events are WAITING right now; the refined loop yields only when nothing is pending and yields immediately (or soon) when something is. It is a heuristic, not a guarantee - not all input is reported - but as a yield-skip gate it removes the majority of pointless yields during interactive use while keeping the safety valve for the quiet-render case.
When should heavy work use a Worker instead of yielding?
When the work is pure computation with no DOM dependency. Yielding keeps the work on the MAIN thread - it interleaves politely but still consumes main-thread CPU, and the total wall time is unchanged. A Worker moves the compute OFF the thread: the page stays fully responsive because the work literally runs elsewhere, at the cost of message-passing (structured clone, transferables) and no DOM access. The decision line: interactive-friendly slicing of DOM-adjacent work (render lists, apply styles, parse into state) = yield; heavy pure computation (parsing megabytes, image processing, crypto) = worker, with scheduler.yield as the fallback for work that cannot move. Both beat the naive synchronous loop; they answer different constraints.