Guide
Scheduler Yield API explained
Harbor Commerce shipped a “sort by relevance” control on its product
catalog: 2,400 SKUs, each scored against a weighted filter object in JavaScript on
the main thread. Lab Lighthouse reported green INP; production mobile p75 on
/catalog jumped to 310 ms within a week. A
LoAF
observer blamed a single 280 ms click handler — almost entirely
Array.prototype.sort with a heavy comparator. Moving the sort to a
Web Worker was
blocked for two sprints (shared state, test harness). The interim fix: chunk the
scoring pass into 80-item batches and call
await scheduler.yield() between batches. INP p75 fell to 220 ms; the
sort still took 260 ms wall-clock, but taps on filter chips responded within 50 ms
because the browser could paint and process input between chunks.
The Scheduler Yield API exposes
scheduler.yield() — a promise-returning function that explicitly
yields the main thread back to the browser so it can run rendering, input, and other
high-priority work before your continuation resumes. It is the cooperative scheduling
primitive the web platform added for the
INP era:
when you cannot move work off-thread yet, you can still avoid monolithic long tasks.
This guide covers the API surface, chunking patterns, how yield differs from
setTimeout(0) and requestAnimationFrame, the Harbor
Commerce catalog refactor, a technique decision table, pitfalls, and a production
checklist alongside
Performance Observer
instrumentation.
What scheduler.yield() does
Browsers schedule main-thread JavaScript, style, layout, paint, and input in a single queue. A synchronous loop that runs 300 ms blocks everything behind it — including the next tap — which is why tasks over 50 ms count as long tasks and hurt Interaction to Next Paint (INP).
scheduler.yield() returns a Promise. When you
await it, the browser may:
- Process pending input events (clicks, keys) that arrived while your task ran.
- Run style and layout for visual updates already queued.
- Paint frames if the deadline allows.
- Resume your async function when higher-priority work is drained.
Minimal pattern:
async function processChunked(items, chunkSize = 50) {
const results = [];
for (let i = 0; i < items.length; i += chunkSize) {
const slice = items.slice(i, i + chunkSize);
for (const item of slice) {
results.push(expensiveTransform(item));
}
if (i + chunkSize < items.length && 'scheduler' in globalThis && scheduler.yield) {
await scheduler.yield();
}
}
return results;
}
Feature detection is required: the API ships in Chromium 129+ and is still gaining
traction in other engines. Guard with
globalThis.scheduler?.yield and fall back to
setTimeout(0) or worker offload where yield is unavailable.
Relationship to the broader Scheduler API
The same scheduler namespace also includes
scheduler.postTask(), which schedules callbacks with explicit
priorities (user-blocking, user-visible,
background). yield() is simpler: it does not enqueue a
new task with a priority label; it pauses your current task and lets the
event loop breathe. Use postTask when you need priority inversion
across unrelated callbacks; use yield when you are mid-algorithm and
need cooperative preemption.
Why yield beats setTimeout(0) for responsiveness
Developers have long broken up work with await new Promise(r => setTimeout(r, 0)).
That works, but the timing guarantees are weaker:
- Timer clamping — Background tabs throttle timers to 1,000 ms or more; yield is intended for active-document responsiveness.
- Priority — Timers run as macrotasks without input
priority;
yield()is designed to let input and rendering interleave ahead of your continuation. - Intent — Code reviewers recognize
scheduler.yield()as a performance primitive, not an arbitrary delay.
requestAnimationFrame aligns to the display refresh (~60 Hz) and is
wrong for non-visual batch jobs — you might wait 16 ms between chunks even
when input is starving. requestIdleCallback runs only in idle periods
and can defer work indefinitely under load; yield targets “I am blocking the
thread now and need to pause briefly.”
Chunking strategies that protect INP
Fixed batch size
Process N items, yield, repeat. Harbor Commerce used 80 SKUs per batch after binary-searching in lab: 40 batches still produced occasional 45 ms slices; 120-item batches let INP spike on low-end Android. Start with 50–100 items for DOM-light transforms; profile with real devices.
Time budget (recommended for heterogeneous work)
Yield when elapsed time exceeds a budget (e.g. 8–12 ms) instead of a fixed count. Heterogeneous comparators, JSON parsing, or tree walks have variable per-item cost; time budgets adapt automatically.
async function runWithBudget(workQueue, budgetMs = 10) {
const deadline = performance.now() + budgetMs;
while (workQueue.length) {
workQueue.shift()();
if (performance.now() >= deadline && workQueue.length) {
if (scheduler?.yield) await scheduler.yield();
else await new Promise(r => setTimeout(r, 0));
deadline = performance.now() + budgetMs;
}
}
}
Progressive UI updates
Pair yields with visible progress: update a skeleton row count or progress bar each chunk so users perceive motion even when total work time is unchanged. Harbor showed “Sorting 1,840 of 2,400…” — support tickets about “frozen catalog” dropped 40% despite identical wall-clock sort duration.
Cancellation and stale results
Async chunking introduces races: the user changes filters while batch 12 of 30 runs.
Keep an incrementing generation token; discard results when
generation !== currentGeneration before committing DOM writes.
Measuring impact: yield is not a substitute for observers
Yield reduces long-task duration per slice but does not remove work. Verify with:
- INP —
evententries via Performance Observer; compare p75 before and after on the interaction that triggered chunked work. - Long tasks — Count tasks > 50 ms; chunking should split one 280 ms task into several sub-50 ms slices (or fewer over-threshold slices).
- LoAF — Confirm
blockingDurationon the invoker dropped; if total script time is unchanged, you fixed responsiveness, not CPU cost. - Total time — Expect 5–15% wall-clock overhead from yield overhead; that is acceptable when INP is the SLO.
Harbor Commerce catalog refactor
After LoAF identified the sort comparator, the team shipped a three-phase plan:
- Immediate (yield chunking) — Precompute scalar scores in
80-item batches with
await scheduler.yield(); sort on the lightweight numeric array in one 12 ms pass. INP p75: 310 ms → 220 ms. - Two-week follow-up — Memoize scores per filter-hash in a
Mapso repeat sorts hit cache. Wall-clock sort: 260 ms → 18 ms on warm cache; INP p75: 220 ms → 145 ms. - Quarterly — Move scoring to a Worker with
Comlink; main thread only applies sorted IDs. INP p75: 95 ms.
The lesson: scheduler.yield() is a bridge, not the destination. It
unblocked a release, restored tap responsiveness, and bought time for proper
worker extraction without a hotfix revert.
Technique decision table
| Approach | Thread | Best when | Watch out for |
|---|---|---|---|
scheduler.yield() |
Main | Chunking synchronous loops; cannot use Workers yet; INP hotfix | Chromium-first support; does not reduce total CPU |
| Web Worker | Background | CPU-heavy transforms, parsing, crypto, sorting large sets | Serialization cost, shared-state complexity, test setup |
scheduler.postTask() |
Main | Priority scheduling across unrelated callbacks | Not for mid-loop chunking; broader API surface |
setTimeout(0) fallback |
Main | Legacy browsers without yield | Weaker input priority; timer throttling in background tabs |
requestIdleCallback |
Main | Non-urgent prefetch, analytics batching | Can starve under load; wrong for user-triggered work |
| Algorithmic optimization | Either | Memoization, indexed sorts, WebAssembly SIMD | Requires profiling first; yield hides but does not fix O(n²) |
Common pitfalls
- Yielding too rarely — 500-item batches still produce long tasks; use time budgets or profile on Moto G-tier hardware.
- Yielding too often — Await per item adds overhead; batch 50–100 items or 8–12 ms slices.
- No feature detect — Calling
scheduler.yield()unguarded throws in unsupported browsers. - Ignoring stale async results — Chunked work without generation tokens causes flicker and wrong sort order.
- DOM writes every chunk — Layout thrash if you reflow
the full list 30 times; batch DOM commits to the final chunk or use
DocumentFragment. - Treating yield as worker replacement — Total main-thread CPU is unchanged; plan worker migration for sustained catalogs.
- Missing loading state — Users perceive chunked work as broken without progress feedback.
- Only testing Lighthouse lab — Lab uses simulated throttling; validate INP in field data (CrUX or RUM).
Production checklist
- Identify long tasks > 50 ms on critical interactions via LoAF or longtask observer.
- Feature-detect
globalThis.scheduler?.yieldwith setTimeout fallback. - Choose batch size or time budget from device lab matrix (low-end Android required).
- Add generation tokens for cancelable filter/search operations.
- Show progress UI during multi-chunk work longer than 100 ms wall-clock.
- Measure INP p75 on the triggering interaction before and after deploy.
- Confirm long-task count and max duration dropped in Performance timeline.
- Defer full-list DOM updates until final chunk or use virtualized lists.
- Document worker migration ticket if yield is interim; set sunset date.
- Re-profile quarterly — catalog growth can outgrow chunk budgets.
Key takeaways
- scheduler.yield() pauses your async function so the browser can process input and paint — the cooperative fix for monolithic main-thread loops.
- Chunk by time budget (8–12 ms) or ~50–100 items; yield between batches to keep slices under the 50 ms long-task threshold.
- Yield improves INP without reducing total CPU — Harbor Commerce cut catalog INP 90 ms while wall-clock sort time stayed similar until memoization landed.
- Prefer Web Workers for sustained CPU work; use yield as a shippable bridge with feature detect and setTimeout fallback.
- Pair chunking with progress UI, cancellation tokens, and LoAF/INP RUM — measure responsiveness, not just algorithm runtime.
Related reading
- Long Animation Frames API explained — per-script blocking attribution for INP triage
- Web Workers explained — moving CPU work off the main thread
- Core Web Vitals explained — INP thresholds and field measurement
- JavaScript event loop explained — tasks, microtasks, and rendering phases