Yielding and Scheduling
Breaking work into pieces the loop can get between — with honest limits: zero is not zero, idle callbacks are not everywhere, and the newer scheduling APIs are not yet universal.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
How do I break long work into pieces the browser can interleave with input and rendering, and which primitive should I actually use?
A person wants to keep using the page while something substantial is happening in it — scroll it, cancel it, or simply see that it is progressing.
Sprinkle await into the loop, or wrap each iteration in setTimeout(fn, 0). Either way the work is now asynchronous, so the page stays responsive.
await on an already-resolved value yields to the microtask queue, which is inside the same turn. The browser gets no chance to render and no chance to dispatch input (The Microtask Checkpoint).
awaiton an already-resolved value yields to the microtask queue, which is inside the same turn. The browser gets no chance to render and no chance to dispatch input (The Microtask Checkpoint).setTimeout(fn, 0)is not zero. Once timers nest past a handful of levels the specification requires a clamp, so a per-item timer loop settles into a slow rhythm and a job that took a moment now takes far longer (Tasks: The Unit That Cannot Be Interrupted).- Yielding gives other code a turn — that is the point — so state can change between chunks. The list you are half-way through rendering can be replaced, and the second half will disagree with the first (State Synchronization).
requestIdleCallbacklooks ideal and is not universally available; support has arrived unevenly and late in some engines, so a codebase that supports older browsers needs a fallback path that behaves differently.- The newer scheduling APIs — posting a task at a named priority, or yielding with a continuation that keeps its place in the queue — are genuinely better primitives and are not available everywhere yet. Writing code that assumes them produces a page that is responsive in one browser and frozen in another.
What is actually happening
In the browser, not in the framework.
- Yielding means ending the current task so the loop can reach the top: select another task, drain microtasks, and take a rendering opportunity if one is due (The Event Loop, Precisely).
- Only a task boundary achieves that. Anything that resumes in the microtask checkpoint — a resolved
await, aqueueMicrotask— is still inside the same turn (The Rendering Opportunity). - Different primitives produce a task from different sources with different scheduling: timers are clamped and throttled;
MessageChannelposts to the message source with no clamp; animation-frame callbacks are tied to the rendering phase; idle callbacks are scheduled by the browser when it judges there is spare time. - A scheduler API that accepts a priority lets the browser make an informed choice instead of guessing — user-blocking work ahead of background work — and a yield with continuation lets a chunked job resume ahead of newly queued background work rather than at the back of the queue. Both are exactly the semantics ad-hoc yielding lacks.
- Yielding is cooperative. The browser cannot take the thread back, so the granularity of your chunks is the granularity of the page's responsiveness, and one chunk that is accidentally large reintroduces the freeze.
- Every boundary is a re-entrancy point. Between chunks, handlers run, state updates land and navigation can happen, so a chunked job must re-check its assumptions rather than carry them across the gap (Cancellation).
What this makes the browser do
And which of it is avoidable.
- Servicing more, smaller tasks: each boundary costs a queue operation, a task selection, a microtask checkpoint and possibly a whole rendering phase.
- Deciding when idle time exists, which requires predicting the current frame's remaining budget — an estimate, not a promise, and one that is deliberately conservative.
- Applying priority when a scheduler API supplies it, and guessing when it does not — which is why the same chunking can behave differently depending on how it was expressed.
- Rendering between chunks, which is the point and is also work: more boundaries means more frames means more style, layout and paint (The Frame Budget).
Which primitive actually yields, and to what
The mistake underneath almost every failed chunking attempt is treating "asynchronous" as a single thing. The primitives below differ in whether they end the task at all, whether they are clamped, and whether the browser gets a rendering opportunity before the continuation runs.
Read the third column first. If it says no, the primitive cannot fix a freeze, no matter how the code reads.
| Primitive | Ends the task? | Browser can render before continuation? | Availability | Use it for |
|---|---|---|---|---|
await a resolved value | No | No | Universal | Nothing scheduling-related; it is a syntax convenience |
queueMicrotask | No | No | Universal | Consistency before paint, never yielding (The Microtask Checkpoint) |
setTimeout(fn, 0) | Yes | Yes | Universal | Simple yielding where the nesting clamp does not matter |
MessageChannel post | Yes | Yes | Universal | Unclamped yielding in a chunk loop — the portable workhorse |
requestAnimationFrame | Yes (next frame) | It is the frame | Universal | Work that belongs to a frame (The Rendering Opportunity) |
requestIdleCallback | Yes | Yes | Uneven historically; needs a fallback | Genuinely optional background work |
| Scheduler post at a priority | Yes | Yes | Not yet universal; feature-detect | Expressing user-blocking versus background intent |
| Scheduler yield with continuation | Yes | Yes | Not yet universal; feature-detect | Chunk loops that must not lose their place in the queue |
A chunk loop that survives a slow device
Two details separate a chunk loop that works from one that does not. It yields on a time budget rather than a fixed count, so a slow device produces smaller chunks automatically; and it yields through a detected primitive, so the best available boundary is used without the code assuming one exists.
The yieldToBrowser helper below is deliberately boring. It is the piece worth centralising, because the alternative is every feature inventing its own and half of them using a clamped nested timer.
function step(i: number) {
for (let n = 0; n < 100 && i < rows.length; n++, i++) appendRow(rows[i]);
if (i < rows.length) setTimeout(() => step(i), 0);
}
step(0);// see renderRows above: // yield when the elapsed budget is spent, not every N items // yield through the best available primitive // check the abort signal at the re-entry point
A hundred rows is a different amount of time on every device, so the fixed-count loop is either still a long task on a slow phone or a needlessly slow one on a fast laptop. And because the timer is nested, it is clamped: the job takes far longer than it needs to on every device. The budgeted version adapts to the machine it is actually running on, and can stop when the user has moved on.
1// One place to decide how we hand the thread back.2const channel = typeof MessageChannel !== "undefined" ? new MessageChannel() : null;3 4function yieldToBrowser(): Promise<void> {5 // Preferred where available: keeps our place in the queue ahead of6 // newly-posted background work. Not universal - feature-detect.7 const s = (globalThis as any).scheduler;8 if (s && typeof s.yield === "function") return s.yield();9 10 // Portable and unclamped, unlike a nested zero-delay timer.11 if (channel) {12 return new Promise((resolve) => {13 channel.port1.onmessage = () => resolve();14 channel.port2.postMessage(null);15 });16 }17 18 // Last resort. Real, and clamped once nested.19 return new Promise((resolve) => setTimeout(resolve, 0));20}21 22async function renderRows(rows: Row[], signal: AbortSignal) {23 let deadline = performance.now() + BUDGET_PER_CHUNK;24 for (let i = 0; i < rows.length; i++) {25 appendRow(rows[i]);26 if (performance.now() >= deadline) {27 await yieldToBrowser();28 // Re-entry point: the world may have moved.29 if (signal.aborted) return;30 deadline = performance.now() + BUDGET_PER_CHUNK;31 }32 }33}The abort check sits immediately after the yield, not at the top of the loop. That is the only place where something else can have happened — and where the job must be willing to stop.
What yielding gives away
Handing the thread back is the goal, and it means other code runs. That is not a caveat to note and move past — it is the reason chunked code needs different invariants from the loop it replaced. Every row below is a real bug that the mitigation itself introduced.
The honest summary: chunking converts a responsiveness problem into a concurrency problem. That is usually the right trade, and it is a trade, not a free win (Out-of-Order Responses).
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Nested zero-delay timers used as the boundary | Page responsive, job many times slower | The specification clamps nested timers after a few levels | Yield through MessageChannel or a scheduler API instead (Tasks: The Unit That Cannot Be Interrupted). |
| Chunk size fixed by item count | Still freezes on mid-range phones | The same count is a much longer task on slower hardware | Budget by elapsed time and re-measure each chunk. |
| Data replaced between chunks | List shows two generations of data at once | The job carried an assumption across a re-entry point | Version the job; abandon it if the version changed (Query Keys and Invalidation). |
| User navigates away mid-job | CPU stays busy; memory is retained | No cancellation is checked at the boundary | Thread an AbortSignal through and check it after every yield (Cancelling a Request Nobody Is Waiting For). |
| Live region updated per chunk | Screen reader interrupts itself continuously | Each chunk is announced as a change | Mark busy at the start, announce once at completion (Live Regions and Announcement). |
| Idle callback used for required work | The work never happens on busy pages, or not at all in some browsers | Idle time is a browser estimate, and availability is uneven | Reserve idle scheduling for optional work; give required work a real priority (Vitals in the Field). |
How to build it
Most important first.
- Yield on a time budget, not a fixed item count. Check elapsed time inside the loop and break when the budget is spent; a count that is right on a laptop is wrong on a phone (The Real Cost of JavaScript).
- Feature-detect the scheduling primitive and fall back deliberately: a scheduler yield where available,
MessageChannelas a portable unclamped boundary, and a timer as the last resort. Write the fallback chain once, in one place. - Give the browser priority information when the API allows it. Chunked rendering of what the user is looking at is not the same priority as warming a cache, and a scheduler that is told the difference schedules better than one that is not.
- Make chunked work cancellable, and check the signal at every boundary. Work that survives a navigation because nobody checked is a leak and a race at the same time (Cancelling a Request Nobody Is Waiting For).
- Re-validate state at each boundary. Treat the resumption point as a new entry into the function, because the world may have changed while you were away.
- Prefer removing or moving the work before chunking it. Chunking keeps the full cost and spreads it; it is the fallback, not the first move (Long Tasks).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Yielding is what makes assistive technology usable during long work, because the accessibility tree is updated on the thread you are handing back (The Accessibility Tree).
- Intermediate states are now visible and reachable. A half-populated list is announced as complete unless the region is marked busy, so the mitigation creates an accessibility obligation (Live Regions and Announcement).
- Never move focus into content that a later chunk will replace. Focus lands, the node is removed, and focus resets to the document — which is one of the most disorienting things a page can do (Focus Management).
- Provide a real way to cancel long work, operable by keyboard, and honour it at the next boundary. A cancel button that only stops the spinner is worse than none (Accessible Component Patterns).
- Announce completion once, at the end, rather than announcing each chunk. A live region updated per chunk produces a stream of interruptions that makes the page unusable with a screen reader.
What can go wrong
- A nested zero-delay timer loop that hits the clamp: the page is responsive and the job takes an order of magnitude longer, so the user is now watching a slow progress bar instead of a frozen page.
- Chunks sized by item count. On a slow device each chunk is long enough to be a long task again, and the mitigation does nothing on precisely the devices that needed it (Interaction Responsiveness).
- A yielded job with no cancellation. The user navigates away, the chunks keep running against a detached tree, and the work both wastes the thread and retains memory (Memory Leaks).
- State torn across a boundary: half the list rendered from the old data and half from the new, with no error anywhere (Reconciliation and Keys).
- An idle-callback path that never runs on a busy page, so the fallback behaviour is what ships and nobody notices until a feature silently does not happen.
- Yielding so often that scheduling overhead dominates. Every boundary has a cost, and per-item yielding on a large collection spends most of the time in the loop rather than in the work.
- Between two chunks, another task can mutate the data being iterated, so a job can render one half of a list from data that no longer exists.
- A cancellation can arrive between chunks; work already dispatched for the current chunk still completes, so cancellation is always "no further chunks", never "undo".
- Two chunked jobs interleave at their boundaries, and their relative progress depends on chunk sizes and on the browser's queue selection — so the finishing order is not determined by which started first (Interleavings: The Schedule Is Part of the Program).
- A navigation between chunks leaves a job writing into a detached tree; the writes succeed silently and are never seen (Detached Nodes and What Keeps Them Alive).
- A yielded job is observable and interruptible by anything else on the page, so a check performed before a boundary cannot be assumed to hold after it. Re-check authorization at the point of the action, and enforce it on the server (What the Frontend Is Responsible For in Auth).
- Scheduling primitives that expose fine-grained timing or input-pending state are constrained by browsers for side-channel reasons, which is part of why they are gated and why their availability is uneven.
- A cancellation that is not honoured leaves work running against state the user believes is gone — including data they expected to be discarded (Storage Security and Durability).
- "
awaityields to the browser." Only if what you await settles from a task source. Awaiting resolved data yields to the microtask queue, which is inside the same turn (The Microtask Checkpoint). - "
setTimeout(fn, 0)is a free yield." It is clamped when nested, throttled when hidden, and subject to whatever else is queued. - "Chunking makes it faster." It makes it slower and makes the page usable while it runs. Those are different goals and conflating them leads to abandoning the change when the total time regresses.
- "
requestIdleCallbackis the modern way to do background work." It is a good fit for genuinely optional work, its availability has varied by engine and version, and on a busy page it may never run at all. - "The new scheduler API solves this." It expresses the intent far better than the older idioms and is not yet available everywhere; treat it as the preferred branch of a fallback chain, not as the baseline (Polyfills vs Transpilation).
Measuring it, and what changes in the field
- In the Performance panel, correctly chunked work looks like a run of short tasks with frames between them. Incorrectly chunked work looks like a run of long tasks with the same total time and no frames (A Mental Model of the Devtools).
- Compare total wall-clock time before and after. Chunking should make it worse; if it did not, you probably did not actually yield (Measure Before Optimising).
- Track interaction latency in the field rather than job duration in the lab. The point of the change is what happens to input while the job runs (Interaction Responsiveness).
- Log which branch of the fallback chain a session took. A scheduling strategy that silently degrades to a timer on most of your traffic is a different product than the one you tested (Real User Monitoring).
- On a slow device the same chunk takes longer, which is exactly why time-budgeted chunking is the only kind that transfers.
- In a hidden tab, timers throttle and rendering opportunities largely stop, so a chunked job slows dramatically or pauses — usually the desired behaviour, and a bug if the job had a deadline (Long-Lived Clients and Version Skew).
- On a page with heavy third-party script, your yields hand the thread to them, so chunking can make your own job much slower without making the page much better (Third-Party Scripts and the Supply Chain).
- On a very large dataset, per-chunk overhead becomes significant and the chunk size has to grow — which is a genuine tension with responsiveness, resolved by measuring rather than by a rule.
- Responsiveness is bought with throughput. Every boundary costs, the browser spends time rendering in between, and the total job takes longer — deliberately.
- Chunked code is harder to reason about: it is re-entrant, it can be cancelled part-way, and it can observe state changing under it. A synchronous loop had none of those properties.
- A feature-detected fallback chain means several behaviours in production, and the one you tested most is the one your development browser chose.
- Priority hints are only as good as the honesty of the caller. If everything is marked urgent, the scheduler has learned nothing and the page is back to first-come, first-served.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThat only a task boundary yields to rendering and input, and that the nested-timer clamp applies, is specified behaviour and holds in every engine; the fallback chain below is portable because it rests on
MessageChanneland timers, which are universally available. - BROWSER-SPECIFIC
requestIdleCallbackwas available in Chromium and Firefox for years before WebKit shipped it, so a product supporting older Safari versions still needs a fallback; the deadline the callback receives is also a browser estimate rather than a guarantee, and how conservative it is differs by engine. - SPEC-EVOLVINGThe prioritised scheduling APIs — posting a task at a named priority and yielding with a continuation — are being standardised and have shipped unevenly, arriving in Chromium first with other engines following at different times; feature-detect at the call site and expect the surface to keep changing rather than pinning to what a given browser supports today.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Programming Languages & Runtime Internals — cooperative yielding in the browser is the same idea as a coroutine yield point, and the trade it makes between throughput and latency is a general property of cooperative scheduling rather than a browser quirk.
- — Testing & Reliability Engineering — chunked, cancellable work has interleavings that only appear under load, and testing it means driving the boundaries deliberately rather than hoping the timing reproduces.