Breaking Up Long Tasks with scheduler.yield
A single synchronous loop that runs for 180 ms is enough to push a route from Good into Poor Interaction to Next Paint (INP): while that loop runs, the browser cannot process the tap that just landed, cannot run a queued paint, and cannot fire your event handler. The fix is to yield the main thread — to deliberately stop, hand control back to the browser so it can paint and process input, then resume. This page shows how to do that correctly with scheduler.yield(), why its continuation semantics matter, how to fall back when it is missing, and the one rule everyone gets wrong: yield after you have painted visible feedback, not before. It builds on the field-measurement context established in the parent INP Tracking & Debugging guide.
A long task is any block of main-thread work lasting more than 50 ms; the browser reports it through the Long Tasks API and it is the single biggest contributor to a high processingDuration in your INP attribution. Breaking one long task into several short ones does not reduce the total CPU work — it inserts gaps where the browser is allowed to respond, and those gaps are what users feel as responsiveness.
Prerequisites
Before splitting a task, confirm the following:
- You have identified the long task to break up. Use the INP attribution build to confirm
processingDurationis the dominant phase, and reproduce the long task in the DevTools Performance panel as described in Debugging INP Spikes in Production. Yielding inside a task that is not actually long adds overhead for nothing. - The work is decomposable. Yielding only helps if the work can pause at a safe boundary — a loop iteration, an item in a batch, a stage in a pipeline. A single un-splittable synchronous call (one giant
JSON.parse, one regex over a megabyte string) cannot be yielded and belongs on a Web Worker instead. - A feature-detection fallback.
scheduler.yield()is Chromium 129+ (and behind a flag earlier). You must run on browsers without it, so a fallback tosetTimeout(0)is mandatory — never assume the API is present. - A way to confirm the result. A
longtaskPerformanceObserver and the DevTools Interactions track, so you can prove the original long task is gone and INP improved rather than just moved.
The scheduling primitives you will choose between:
| Primitive | Resumes where | Priority control | Availability | Use when |
|---|---|---|---|---|
scheduler.yield() |
Front of the queue (continuation) | Inherits caller | Chromium 129+ | Splitting an in-progress task you want to finish fast |
setTimeout(cb, 0) |
Back of the task queue | None | Universal | Fallback only; competes with other timers |
scheduler.postTask() |
New task at chosen priority | user-blocking / user-visible / background |
Chromium 94+ | Scheduling distinct units of work by importance |
requestIdleCallback() |
When the browser is idle | Idle only | No Safari | Non-urgent work that can wait for a free moment |
isInputPending() |
n/a (a query, not a yield) | n/a | Chromium 87+ | Deciding whether to yield right now |
How to break up a long task with scheduler.yield
Step 1 — Write a yieldToMain() with a setTimeout fallback
Never call scheduler.yield() bare. Wrap it so the same call site works on every browser, falling back to a setTimeout(0) macrotask when the API is absent.
// Resolves after handing the main thread back to the browser.
function yieldToMain() {
if (typeof scheduler !== 'undefined' && typeof scheduler.yield === 'function') {
return scheduler.yield();
}
// Fallback: a 0 ms timer is a macrotask, so paint and input can run first.
return new Promise((resolve) => setTimeout(resolve, 0));
}
Why: scheduler.yield() and the setTimeout(0) fallback differ in one important way. scheduler.yield() returns a promise whose continuation is placed at the front of the scheduler queue, so your work resumes ahead of unrelated tasks the browser may have queued meanwhile — your operation still finishes quickly. setTimeout(0) goes to the back of the task queue, so it competes with every other timer and any work queued during the gap. The fallback keeps the page responsive everywhere, but only the native call preserves throughput on a busy thread.
Step 2 — Chunk the long loop and yield between chunks
Take the synchronous loop the profiler flagged and yield periodically. Yield on a time budget, not a fixed iteration count, so the gap cadence is stable regardless of how heavy each item is.
async function processAll(items, perItem) {
const results = [];
let chunkStart = performance.now();
for (let i = 0; i < items.length; i++) {
results.push(perItem(items[i]));
// After ~50 ms of work, hand the thread back so a paint/tap can run.
if (performance.now() - chunkStart > 50) {
await yieldToMain();
chunkStart = performance.now();
}
}
return results;
}
Why: a fixed i % 100 boundary yields too often on cheap items (overhead) and too rarely on expensive ones (you blow past 50 ms and still register a long task). Budgeting by elapsed time caps every chunk under the 50 ms long-task threshold, which is exactly the line the Long Tasks API draws. The browser gets a guaranteed opening to render and to process input between every chunk.
Step 3 — Paint visible feedback, then yield
When an interaction kicks off heavy work, render the user-facing acknowledgement (spinner, disabled button, optimistic row) first, yield to let that paint commit, and only then start the heavy loop. INP measures input to next paint — the paint that shows the user something happened.
button.addEventListener('click', async () => {
// 1. Immediate visual feedback — this is the paint INP is timing.
button.setAttribute('aria-busy', 'true');
button.disabled = true;
list.classList.add('is-loading');
// 2. Yield so the browser commits that paint before the heavy work starts.
await yieldToMain();
// 3. Now run the long, chunked work off the interaction's critical path.
const rows = await processAll(dataset, renderRow);
list.replaceChildren(...rows);
button.removeAttribute('aria-busy');
button.disabled = false;
});
Why: this is the rule most yield implementations get backwards. If you start the heavy loop and yield only during it, the user’s tap is acknowledged late — the spinner paints after the work, and INP records the full delay. By painting feedback first and yielding before the loop, the next paint lands within a few milliseconds of the tap, so INP stays in the Good band even though total work is unchanged. The same discipline keeps avoidable layout shifts out of the result, which matters for Cumulative Layout Shift (CLS) when the heavy work later inserts content — reserve the space before yielding.
Step 4 — Skip the yield when no input is pending
A yield is not free: each one costs a scheduling round-trip. Use navigator.scheduling.isInputPending() to yield only when the browser actually has input waiting, so you keep throughput high on a quiet thread and only break for responsiveness when it matters.
async function processAllSmart(items, perItem) {
const results = [];
let chunkStart = performance.now();
const scheduling = navigator.scheduling;
for (let i = 0; i < items.length; i++) {
results.push(perItem(items[i]));
const overBudget = performance.now() - chunkStart > 50;
const inputWaiting = scheduling?.isInputPending?.() === true;
// Yield if input is pending, or unconditionally past the 50 ms cap.
if (inputWaiting || overBudget) {
await yieldToMain();
chunkStart = performance.now();
}
}
return results;
}
Why: isInputPending() returns true only when a discrete input event (tap, key, click) is queued behind the running task. Yielding the instant that happens means a real user interaction is served almost immediately, while a thread with no waiting input keeps grinding at full speed. The > 50 ms cap is the floor that still prevents a single chunk from ever registering as a long task even when no input arrives. On browsers without the API, the optional-chaining guard simply falls through to the time budget.
Step 5 — Schedule distinct work units by priority with postTask
When the work is several separate operations rather than one loop — render the visible viewport, then prefetch the next page, then warm a cache — express their relative importance with scheduler.postTask() and its three priorities instead of yielding inside one function.
function renderDashboard() {
// user-blocking: the user is waiting on this paint.
scheduler.postTask(() => renderVisibleWidgets(), { priority: 'user-blocking' });
// user-visible (default): matters, but can run after the critical paint.
scheduler.postTask(() => hydrateBelowFold(), { priority: 'user-visible' });
// background: never let this contend with interaction work.
scheduler.postTask(() => prefetchNextRoute(), { priority: 'background' });
}
Why: postTask lets the browser’s own scheduler interleave your work with input and rendering by priority. user-blocking tasks run ahead of user-visible, which run ahead of background, and the scheduler can preempt lower-priority tasks when input arrives. This is strictly better than three setTimeout(0) calls, which all land at the same undifferentiated priority and run first-in-first-out regardless of what the user is doing.
Verifying it works
- DevTools Performance panel: record the interaction with 4× CPU throttling and confirm the once-solid 180 ms block is now several sub-50 ms tasks with no red long-task triangle. The Interactions track bar should fit under the 200 ms budget.
- Console
longtaskobserver: keep this running while you reproduce — after the fix it should log nothing over the interaction, where it previously logged the long task.
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn(`long task: ${Math.round(entry.duration)} ms`);
}
}).observe({ type: 'longtask', buffered: true });
- RUM dashboard: the field INP attribution for the route should show
processingDurationdropping and the bucket’s p75 INP returning under 200 ms. Local DevTools proves the task split on one machine; only the field p75 proves it across the real device and network mix.
Edge cases & gotchas
scheduler.yield()is not in Safari or Firefox. Both fall through to thesetTimeout(0)path, which works but loses the front-of-queue continuation guarantee. Your worst-case responsiveness is the fallback’s, so test there; do not ship a path that assumes the native API.- Yielding inside a
requestAnimationFramecallback fights the renderer. rAF runs right before paint; awaiting a yield there pushes your work past the frame you were trying to hit. Do heavy work in a normal task withyieldToMain(), and reserve rAF for the visual update itself. - Awaiting in a loop without a budget can starve the work. If you
await yieldToMain()every iteration, scheduling overhead can dominate and a long job never finishes. Always gate the yield behind the time budget (orisInputPending()), not every pass. isInputPending()ignores continuous events by default. It reports discrete input but not rawmousemove/pointermoveunless you pass{ includeContinuous: true }. For a scroll- or drag-driven handler, opt in or you will never yield for that input.- State can change across a yield. Because the browser ran other tasks during the gap, the DOM or your data may have been mutated — a row you were about to update could be gone. Re-check invariants after
await yieldToMain()exactly as you would after anyawait. - Background tabs throttle timers. The
setTimeout(0)fallback is clamped heavily in hidden tabs, so a yielded job stalls in the background. That is usually fine for interaction work, but do not rely on the fallback to make progress while the tab is hidden.
FAQ
Does scheduler.yield() make my code run faster?
No — it does the opposite to total wall-clock time, adding small scheduling gaps. It does not reduce CPU work; it inserts openings where the browser can paint and process input. The user perceives the page as faster because their tap is answered within the budget, and INP improves, but the underlying work takes marginally longer overall.
How is scheduler.yield() different from setTimeout(0)?
Both pause and resume your code, but scheduler.yield() places its continuation at the front of the scheduler queue, so your operation resumes ahead of unrelated queued tasks and finishes promptly. setTimeout(0) queues a macrotask at the back, competing with every other timer. Use scheduler.yield() when available and setTimeout(0) only as a fallback.
When should I use postTask instead of yield?
Use scheduler.postTask() when you have several distinct units of work with different urgency — a critical paint, a deferred hydration, a background prefetch — and want the browser to interleave them by user-blocking, user-visible, or background priority. Use scheduler.yield() when you are splitting one in-progress task, such as a long loop, that you want to finish quickly while staying responsive.
Related
- INP Tracking & Debugging — the parent guide on measuring INP in the field and the input-to-paint algorithm.
- Debugging INP Spikes in Production — how to locate the long task that this yielding strategy fixes.
- Instrumenting INP in the Next.js App Router — applying these yield patterns inside a React Server Components hydration boundary.