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:

  • INPevent entries 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 blockingDuration on 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:

  1. 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.
  2. Two-week follow-up — Memoize scores per filter-hash in a Map so repeat sorts hit cache. Wall-clock sort: 260 ms → 18 ms on warm cache; INP p75: 220 ms → 145 ms.
  3. 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?.yield with 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