Breaking Up Long Tasks with scheduler.yield()
How scheduler.yield() hands the main thread back without losing your place in the task queue — with cross-browser fallbacks and LoAF measurement.
Every frontend performance guide repeats the same instruction: don't block the main thread. The hard part was never knowing that, it was yielding without making the code worse. For years the standard advice was to wrap work in setTimeout, which does hand control back to the browser but punts your continuation to the back of the task queue. The Prioritized Task Scheduling API changed that trade, and it is now supported widely enough to belong in your default toolkit.
What a long task actually costs
The main thread can only process one task at a time, and any task that takes longer than 50 milliseconds is a long task.[2] The browser blocks interactions while a task of any length runs, which stays invisible to users until tasks get long, at which point controls start to feel broken, including built-in form elements the page never touched.[2]
The metric that catches this is Interaction to Next Paint, where it is recommended to respond in 200 milliseconds or less.[3] Notice what that budget measures: not total work, but the delay between an input and the next painted frame. A 300 ms function that paints something at the 40 ms mark is a far better experience than a 300 ms function that paints nothing until it finishes, so the fix is often ordering rather than raw speed.[3]
What scheduler.yield() changes
scheduler.yield() pauses the function at the await and yields to the main thread, breaking up the task, and the rest of the function is then scheduled to run in a new event-loop task.[1] The specific benefit over other yielding approaches is that its continuation is prioritized, so if you yield in the middle of a task, the continuation of the current task runs before any other similar tasks are started.[2] That is the difference between yielding and abandoning your turn: with setTimeout, the deferred half of your function joins the end of the queue behind whatever else the page has scheduled, including third-party scripts you do not control.[2]
In practice, you place the yield between the work the user can see and the work they cannot:
async function saveSettings() {
// Do critical work that is user-visible:
validateForm();
showSpinner();
updateUI();
// Yield to the main thread:
await scheduler.yield();
// Work that isn't user-visible, continued in a separate task:
saveToDatabase();
sendAnalytics();
}saveSettings() is now split across two tasks, so layout and paint can run in between, giving the user a quicker visual response as measured by the now much shorter pointer interaction.[2] You should not yield after every call, though: if two functions both produce critical UI updates, keep them in the same task and yield after the pair instead.[2]
One subtlety bites people immediately. scheduler.yield() only schedules a task, it is the await that actually pauses execution, so forgetting the await inside an iterative method like Array.prototype.forEach gets you all of the restructuring and none of the responsiveness.[2]
Shipping it where Safari doesn't have it
scheduler.yield() is not yet supported in all browsers, so a fallback is needed.[2] MDN is blunt about the current state: the feature is not Baseline because it does not work in some of the most widely-used browsers.[4] The pragmatic answer is a small helper:
function yieldToMain(): Promise<void> {
if (globalThis.scheduler?.yield) {
return scheduler.yield();
}
// Fall back to yielding with setTimeout.
return new Promise((resolve) => setTimeout(resolve, 0));
}Make sure you check for both the global scheduler and a yield method on it, because feature-detecting the namespace alone is not sufficient.[2] Browsers without support lose the prioritized continuation but still yield, which is the part that keeps the page responsive.[2]
If your code genuinely cannot tolerate an unpredictable delay, for instance on a known-busy page where yielding risks not completing work for some time, you can treat scheduler.yield() as progressive enhancement and only yield where it exists.[2] The scheduler-polyfill package is the other option: a small polyfill for scheduler.postTask and scheduler.yield that uses a combination of methods to emulate much of the power of the scheduling APIs in other browsers, though scheduler.yield() priority inheritance is not supported there.[1]
Yield less than you think
Yielding is not free, and a per-item yield over a queue of cheap jobs can spend more time resuming tasks than doing work.[2] A common deadline is 50 milliseconds to try to keep tasks from becoming long tasks, but it can be adjusted as a tradeoff between responsiveness and time to complete the job queue.[2] The pattern is to run jobs, compare performance.now() against the last yield, and only hand control back once you cross the deadline:
async function runJobs(jobs: Array<() => void>, deadline = 50) {
let lastYield = performance.now();
for (const job of jobs) {
job();
if (performance.now() - lastYield > deadline) {
await yieldToMain();
lastYield = performance.now();
}
}
}The continuation of runJobs() is still prioritized, so higher-priority work such as visually responding to input can run without waiting for the entire list to finish.[2]
Measure with LoAF, not with vibes
A profiler screenshot rarely tells you which of your frames actually blocked input, which is why the Long Animation Frames API matters. It shipped in Chrome 123, following an origin trial from Chrome 116 to Chrome 122, and it measures long animation frames, meaning rendering updates delayed beyond 50 milliseconds, rather than individual tasks.[3]
The field worth alerting on is blockingDuration: the total duration for which the animation frame would block processing of input or other high priority tasks.[3] A frame can be long without being blocking, and those two failure modes need different fixes, because reducing blocking duration is what buys responsiveness as measured by INP, while reducing duration overall is what fixes a laggy-feeling scroll.[3]
if (PerformanceObserver.supportedEntryTypes.includes("long-animation-frame")) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Attribute blocking time back to the scripts that caused it.
console.log(entry.duration, entry.blockingDuration, entry.scripts);
}
});
observer.observe({ type: "long-animation-frame", buffered: true });
}Prefer PerformanceObserver over getEntriesByType, since the performance entry buffer has a maximum size and newer entries are dropped once it fills.[3]
The short version
Put the yield between user-visible work and background work. Feature-detect globalThis.scheduler?.yield rather than scheduler. Always await, and never yield inside forEach. Batch with a deadline instead of yielding per item. Then watch blockingDuration in the field, because the whole point of yielding is the frame the user actually sees, not the milliseconds you saved on paper.
Sources
[1] Use scheduler.yield() to break up long tasks — Chrome for Developers — https://developer.chrome.com/blog/use-scheduler-yield
[2] Optimize long tasks — web.dev — https://web.dev/articles/optimize-long-tasks
[3] Long Animation Frames API — Chrome for Developers — https://developer.chrome.com/docs/web-platform/long-animation-frames
[4] Scheduler: yield() method — MDN Web Docs — https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/yield