Optimizing INP with scheduler.yield(): Breaking Up Long Tasks to Stay Under 200ms

This guide extends the interactivity work in Core Web Vitals & Measurement with the modern main-thread scheduling APIs that finally make cooperative yielding ergonomic.

Interaction to Next Paint (INP) regresses for one dominant reason: a single JavaScript task occupies the main thread long enough that the browser cannot run an event callback or paint the next frame on time. Any task that runs longer than 50ms is, by definition, a long task, and a long task that overlaps a user interaction pushes that interaction's total latency past the 200ms "good" boundary. Because INP reports the worst (or near-worst) interaction across the whole page visit, a single unsplit batch buried in one handler is enough to fail the metric in the field even when the median interaction is fast. The fix is not to do less work in aggregate — it is to slice that work into chunks shorter than the long-task budget and hand control back to the browser between chunks, so a queued click or keystroke can jump ahead of the remaining work and be processed within budget.

The naive way to yield is setTimeout(0), which works but is blunt: the continuation lands at the back of the task queue, the delay is clamped, and the browser has no signal about how urgent the resumed work is. The scheduler.yield() API (shipping in Chrome 129+ and Firefox 142+) and the broader Prioritized Task Scheduling API (scheduler.postTask()) replace that guesswork with explicit, queue-aware scheduling: yield continuations resume ahead of fresh tasks, and postTask lets you label work as user-blocking, user-visible, or background so the browser orders it against rendering correctly. This guide walks the full diagnostic loop — baseline the interaction, isolate which INP phase dominates, insert yield points where they actually help, and lock the result behind a CI budget — rather than scattering yields and hoping.

Splitting a long task with scheduler.yield() Before: one 220ms task blocks input. After: five chunks under 50ms each, with a yield point where the queued click runs. Long task vs. yielded chunks (budget 50ms) Before one task: 220ms (blocks input) click input wait → INP fail After 44ms 44ms 44ms 44ms 44ms yield → click runs here Each chunk stays under the 50ms budget; queued input runs at a yield point. scheduler.yield() re-queues continuation ahead of fresh tasks, unlike setTimeout(0).

1. Environment Setup and Feature Detection

Confirm the runtime first. scheduler.yield() requires Chromium 129+ or Firefox 142+; scheduler.postTask() has been stable in Chromium since 94 and is available in Firefox 142+. Safari ships neither at the time of writing, so every production path needs a fallback. Install the official polyfill so the API surface is uniform across browsers and your code paths do not branch on every call:

bash
# trade-off: the polyfill emulates postTask/yield via setTimeout + a priority queue,
# so it restores the API shape but NOT native scheduling integration — skip it if you
# already gate all calls behind feature detection and only target Chromium 129+.
npm install scheduler-polyfill

Gate behavior on capability, not on a browser string. A single yieldToMain() helper centralizes the decision so the rest of the codebase stays declarative:

javascript
// One yield helper, used everywhere. Centralizing the fallback keeps call sites clean.
function yieldToMain() {
  if ('scheduler' in window && 'yield' in window.scheduler) {
    return window.scheduler.yield();
  }
  // trade-off: setTimeout(0) yields but drops to the back of the task queue, so a
  // burst of other tasks can starve your continuation — acceptable only as a fallback.
  return new Promise((resolve) => setTimeout(resolve, 0));
}

Pair this with the interactivity baseline from Optimizing First Input Delay (FID), which establishes the main-thread hygiene that yielding builds on.

2. Capture a Baseline with Long-Task and Event-Timing Data

You cannot fix what you have not located. Record real-user INP with the web-vitals attribution build, which splits each interaction into input delay, processing duration, and presentation delay, then correlate the worst interactions against PerformanceLongTaskTiming entries to find the blocking script.

javascript
import { onINP } from 'web-vitals/attribution';

onINP(({ value, attribution }) => {
  // attribution.longAnimationFrameEntries pinpoints the blocking script + its duration.
  navigator.sendBeacon('/rum/inp', JSON.stringify({
    value,
    phase: {
      input: attribution.inputDelay,
      processing: attribution.processingDuration,
      presentation: attribution.presentationDelay,
    },
    target: attribution.interactionTarget,
  }));
  // trade-off: reportAllChanges:true gives finer data but more beacons — leave it off
  // in high-traffic production and rely on the p75 final value instead.
});

For lab capture, open the Performance panel under 4x CPU throttling, run the interaction, and read the Long Tasks track. Anything wider than 50ms that overlaps your interaction is a yield candidate. Switch to the Interactions track to see the recorded INP for the exact action, then drill into the flame chart beneath it to see which function dominates the long task. Record the baseline number — the worst interaction's INP and the width of its longest task — because every later step is judged against it. The deeper workflow for replaying and ranking interactions lives in profiling event handlers for INP.

3. Isolate the Dominant Bottleneck

INP latency has three additive parts (deconstructed below), so before adding yield points decide which part dominates. If input delay is large, the main thread was already busy when the interaction arrived — yield earlier, inside whatever task was running at click time. If processing duration is large, your own handler is the long task — yield inside it. If presentation delay is large, yielding will not help; that is a rendering/paint problem (large DOM, expensive style recalc) better addressed with content-visibility and reduced commit size.

Use the attribution phase breakdown from step 2 to label each slow interaction. Yielding is the correct tool only for the input-delay and processing-duration phases; spending effort adding yield points to a presentation-bound interaction wastes time and can even hurt, because the extra event-loop turns delay the paint you were trying to speed up. A quick rule of thumb: if the flame chart shows your script running when the click arrives, the problem is input delay; if it shows your handler running for a long stretch after the click, it is processing duration; if the gap sits between your handler finishing and the next paint, it is presentation delay. Only the first two are scheduling problems.

4. Apply the Fix: Yield Points Inside Long Work

The core pattern is the await-yield loop: process a batch of work, check the elapsed time against the budget, and await yieldToMain() once the budget is exhausted. Yielding by item count is fragile because item cost varies; yield by elapsed time so each chunk stays under 50ms regardless of payload.

javascript
async function processInChunks(items, handleItem) {
  let deadline = performance.now() + 50; // 50ms long-task budget per chunk
  for (let i = 0; i < items.length; i++) {
    handleItem(items[i]);
    if (performance.now() >= deadline) {
      await yieldToMain();          // hands control back; queued input runs now
      deadline = performance.now() + 50; // reset budget after resuming
    }
  }
  // trade-off: yielding by elapsed time adds a performance.now() call per item; for
  // tight numeric loops over millions of cheap items, batch the time check every Nth
  // iteration or move the whole job to a worker instead.
}

Why this beats setTimeout(0): when you setTimeout(0), the continuation is appended to the back of the task queue, so any task already queued — including other yielded continuations or low-priority work — runs first, and your "0ms" delay is realistically clamped and reordered. scheduler.yield() instead returns a promise whose continuation is scheduled at a higher priority than fresh postTask tasks, so your loop resumes promptly after pending user input is handled, not after an unbounded backlog. In practice this turns "yield and hope" into "yield and resume," which is what keeps a long batch from ballooning total wall-clock time.

For work that is not a tight loop — independent units like hydrating widgets, warming caches, or prefetching — schedule each with scheduler.postTask() and an explicit priority instead of hand-rolling a queue:

javascript
function schedule(fn, priority = 'user-visible') {
  if ('scheduler' in window && 'postTask' in window.scheduler) {
    // priorities: 'user-blocking' (highest), 'user-visible' (default), 'background'
    return window.scheduler.postTask(fn, { priority });
  }
  // trade-off: the fallback ignores priority entirely, so on Safari a 'background'
  // job competes equally with 'user-blocking' work — keep fallback jobs small.
  return Promise.resolve().then(fn);
}

schedule(() => renderAboveFold(), 'user-blocking');   // must finish before next paint
schedule(() => prefetchNextRoute(), 'background');     // can wait for idle

user-blocking work is scheduled ahead of rendering and should be reserved for the handful of tasks that must complete before the next frame — committing the visible result of an interaction, for example. user-visible (the default) covers most work the user will notice but that can tolerate a frame of latency. background work yields to nearly everything and is the right home for analytics warm-up, log flushing, and speculative prefetch; scheduling those at background keeps them from ever competing with an interaction. The practical discipline is to pick the lowest priority a task can tolerate rather than defaulting everything to user-visible, because over-prioritizing reintroduces the contention you are trying to remove.

Truly CPU-bound jobs (parsing, diffing, image work) should leave the main thread entirely — see offloading work to web workers with Comlink, since no amount of yielding makes a 400ms parse cheap, it only makes it interruptible. Yielding is the right tool when the total work is acceptable but its shape is wrong (one long block instead of many short ones); a worker is the right tool when the total work itself is too expensive to run alongside rendering at all.

Deconstructing INP: Input Delay + Processing + Presentation Delay

INP for a given interaction is the sum of three measurable phases, and each has its own diagnostic and its own budget against the 200ms total:

  • Input delay — time from the user's action until the first event listener begins running. Caused by the main thread being busy with an unrelated task when the interaction arrives. Target well under 50ms. The cure is yielding in the busy task, not in the handler.
  • Processing duration — time spent running all event listeners for the interaction (the click, input, change, plus framework-internal work like state updates). This is usually the largest phase in JavaScript-heavy apps. Target < 100ms. The cure is splitting the handler with the await-yield loop and deferring non-urgent work.
  • Presentation delay — time from listeners finishing until the browser paints the next frame reflecting the change. Caused by large style/layout/paint cost. Target < 50ms. The cure is reducing commit size, not scheduling.

A common failure is a handler that measures 40ms of processing but ships 300ms INP because a 220ms task was already mid-flight at click time, inflating input delay. The phase breakdown tells you to yield in the upstream task. Cross-reference this model with improving INP for complex single page applications, where router transitions and hydration commonly inflate the input-delay phase.

Advanced Diagnostics and Edge-Case Failure Modes

Yield starvation under continuation pressure. If you sprinkle scheduler.yield() across many concurrent loops, their continuations contend. Because yield continuations are prioritized over fresh tasks, a flood of them can still delay genuinely new user-blocking work. Prefer one coordinated queue over dozens of independent yielding loops.

AbortController to cancel stale work. When a user re-interacts (types another character, clicks a different tab), the in-flight chunked job is now wasted. scheduler.postTask() accepts an AbortSignal; pass one and abort it on the next interaction so you never burn budget computing a result nobody will see.

javascript
let controller = new AbortController();
function startWork(items) {
  controller.abort();                 // cancel the previous, now-stale run
  controller = new AbortController();
  const signal = controller.signal;
  return window.scheduler.postTask(async () => {
    let deadline = performance.now() + 50;
    for (const item of items) {
      if (signal.aborted) return;     // stop the moment a newer interaction supersedes us
      process(item);
      if (performance.now() >= deadline) {
        await window.scheduler.yield();
        deadline = performance.now() + 50;
      }
    }
  }, { signal, priority: 'user-visible' });
  // trade-off: abort-on-reinteraction is ideal for search/filter; for "save" or
  // payment work, aborting a partially-applied mutation can corrupt state — never
  // cancel non-idempotent jobs.
}

Framework scheduling collisions. React's concurrent scheduler, Vue's nextTick, and Angular zones already run their own task queues. Layering manual scheduler.yield() inside a React render is counterproductive — let the framework's startTransition mark the work as non-urgent and reserve raw yielding for plain imperative loops in event handlers and data transforms. The boundary is clean in practice: yield inside the imperative code you wrote (a loop building an index, a transform over a large array), and use the framework primitive for anything that triggers a re-render. Mixing the two — yielding partway through a synchronous render — produces torn UI and unpredictable commit timing.

Misreading "zero" delay. Engineers often assume setTimeout(0) and scheduler.yield() are interchangeable because both "yield to the event loop." They are not: under load, setTimeout(0) continuations can be delayed by tens of milliseconds and reordered behind unrelated tasks, so a chunked job that should take 250ms of wall-clock time stretches well past it. Measuring the wall-clock duration of the whole chunked operation — not just per-chunk INP — is the fastest way to see the difference between the two APIs in your own app.

Validation and Budgeting in CI

Yielding is only proven by the long-task profile and the field INP after it ships. Assert both. In Lighthouse CI, fail the build when Total Blocking Time regresses, since TBT is the lab proxy that moves when long tasks shrink:

javascript
// lighthouserc.js
module.exports = {
  ci: {
    assert: {
      assertions: {
        // budget the lab proxy for main-thread blocking
        'total-blocking-time': ['error', { maxNumericValue: 200 }],
        'mainthread-work-breakdown': ['warn', { maxNumericValue: 2000 }],
        'max-potential-fid': ['warn', { maxNumericValue: 130 }],
      },
    },
  },
};
// trade-off: TBT is a lab proxy, not INP itself — a green TBT with red field INP means
// the slow interaction happens after load (route change, modal), so pair this with RUM.

Add a synthetic interaction assertion that fails if any single task during the scripted interaction exceeds the budget — a Puppeteer or Playwright script that performs the interaction while recording PerformanceLongTaskTiming, then asserts no entry exceeds 50ms. This catches the regression that TBT misses: a slow interaction that only happens after load, on a route change or modal open, where the lab page-load metrics stay green. Run the scripted check in the same pipeline stage as Lighthouse so a single CI gate covers both load-time and interaction-time blocking. Finally, confirm field INP at the p75 in your RUM dashboard before and after the change; treat a deploy as proven only when the field number moves, since synthetic devices rarely reproduce the slow tail of real hardware. The lab gate prevents the regression from merging; the field check confirms the win on real devices. The full CI harness, including custom audits, is covered in the Lighthouse CI setup for frontend pipelines.