This post introduces the scheduler.yield
API and explains how you can use it to optimise the performance of your web applications. If you want to follow along with the code snippets or interactive demos, be sure to read the browser support section to ensure that your browser supports this feature.
Introduction
In the context of web performance, scheduling is the browser's way of deciding which tasks to run (JavaScript, page rendering, etc.) and when to run them. Scheduling is a key concept that can impact the user experience of your web applications.
While not every website needs to worry about scheduling, scheduling is an important feature to understand as it offers precise timing and control over how your JavaScript is scheduled.
What is scheduler.yield
?
The scheduler.yield
API is not yet supported in all browsers.
scheduler.yield()
is an experimental method available in JavaScript:
scheduler.yield(); // Promise {<pending>}
The scheduler.yield()
method is used to yield control back to the browser's scheduler, allowing other important tasks to run. It's the browser that decides what's considered "important".
This can be useful when you want to ensure that your JavaScript code doesn't block the main thread and negatively impact the user experience.
You might be wondering, what other work does the browser need to do? The browser scheduler is responsible for a lot of things, such as:
- Rendering the page
- Garbage collection
- Handling user input
Let's focus on the last one, handling user input. When the main thread is blocked, the browser can't respond to most types of user input. This can lead to a poor user experience and result in poor web performance metric scores, like Interaction to Next Paint as part of Core Web Vitals.
In the following diagram, the "Before scheduler.yield" and "After scheduler.yield" examples both take 3.5 seconds to complete. However, the "After scheduler.yield" version is able to run an important task related to user input after 1 second, while the "Before scheduler.yield" example is only able to run the important task after 3 seconds.
If you find terms like "main thread", "browser scheduler" and "blocking" confusing, know that you're not alone! Traditionally, web performance was centered around network performance, but as web applications have become more complex, the focus has shifted to runtime performance - especially for JavaScript-heavy web apps.
- Main thread: A browser process that handles different tasks like rendering the page.
- Browser scheduler: The browser scheduler decides which tasks to run and when to run them.
- Blocking: When a task is running on the main thread, it can stop other tasks from running.
Note on code examples
In the next few sections, you'll see code examples that demonstrate how to use scheduler.yield
. Before that however, we first need an example of a slow running function that blocks the main thread. The implementation of the following function isn't important:
// A helper function to block the main thread, for demonstration purposes only
function blockMainThread(duration) {
const startTime = Date.now();
while (Date.now() - startTime < duration) {
// Blocking the main thread
}
}
The main takeaway here is that when blockMainThread
is called, it blocks the main thread for (roughly) the specified duration.
A basic example of scheduler.yield
In this section, you'll see a minimal before and after example of using scheduler.yield
.
A task that blocks even for 500ms is a Long Task and needs fixing.
First, the "before" which blocks the main thread for 1 second, and does not use scheduler.yield
:
Before:
// This function blocks for 1 continuous second (500ms + 500ms)
function blocksContinuously() {
blockMainThread(500);
blockMainThread(500);
}
And here's the "after" which does uses scheduler.yield
:
After:
async function blocksInChunks() {
// Blocks for 500ms, then yields to the browser scheduler
blockMainThread(500);
await scheduler.yield(); // The browser scheduler can run other tasks at this point
// Blocks for another 500ms and returns
blockMainThread(500);
}
And here's the difference shown in a DevTools Performance profile:
Here are some key points to note from the DevTools screenshot:
Without scheduler.yield | With scheduler.yield | |
---|---|---|
Button responsiveness | The button :active state freezes for 1 second | The button active state freezes for 500ms |
User experience | The user is unable to interact with the page for 1 second | The user can interact with the page after 500ms |
Long Task | The Long Task takes 1 second to complete | The Long Task takes 500ms to complete |
scheduler.yield
returns a promise. After the promise is resolved, the remaining code in blocksInChunks
will run.
There's a big difference between the two implementations shown earlier:
- Without
scheduler.yield
: TheblocksContinuously
function blocks the main thread for 1 continuous second. - With
scheduler.yield
: TheblocksInChunks
function blocks for 500ms, yields to the browser scheduler, and then blocks for another 500ms.
For many website use cases, it's inevitable that some JavaScript will block the main thread. However there's a big difference in blocking the main thread for 1 continuous second, versus blocking for 500ms, yielding to the browser scheduler, and then blocking for another 500ms. In the example case of a 500ms blocking task, you would go a step further and break down the 500ms block into smaller chunks, while yielding to the browser scheduler in between each chunk.
The previous example is kept minimal for educational purposes. You might be wondering, how would DevTools present this information when a user interaction occurs. Continue with this article to learn more.
Demo: using scheduler.yield
with a user interaction
Here's a interactive demo of using scheduler.yield
that highlights a user interaction scenario.
Follow these steps to see how using scheduler.yield results in a faster interaction:
- Choose either "One Long Task" or "Many Smaller Tasks" button.
- One Long Task: Does not use
scheduler.yield
. - Many Smaller Tasks: Uses
scheduler.yield
.
- Immediately after clicking one of those buttons, quickly click the "Accept Cookies" button.
- Observe the delay in being notified "Here are your cookies"
Bonus: Record a DevTools performance profile of the above steps. Can you observe how JavaScript execution appears differently depending on whether you clicked "One Long Task" or "Many Smaller Tasks"? If not, continue reading to see what it looks like.
In this scenario, there is a long blocking task that does not use scheduler.yield
. During the task, the user clicks on an "Accept cookies" button. The browser is unable to respond to this user input until the long task is complete.
Note if you are not sure on what to focus on within the following screenshot, skip it, and look at the next one.
Here's an annotated version of the previous screenshot:
In this next scenario, the long blocking task has been split up into chunks, and scheduler.yield
is used throughout. During the task, the user clicks on an "Accept cookies" button. The browser is able to respond to this user input after the first chunk of work is complete.
Note if you are not sure on what to focus on within the following screenshot, skip it, and look at the next one.
Here's an annotated version of the previous screenshot:
You can install the Site Speed by DebugBear Extension to see an overlay of Interaction to Next Paint (INP) times on your website, amongst other metrics.
Cancelling a scheduled task after yielding with a TaskController
The continuation of work is the code that runs after the scheduler.yield
call. You can cancel this continuation by using the following:
- The
abort
method of a TaskController - The
postTask
method of thescheduler
object - The
signal
option of thescheduler.yield
function
The main point to highlight in the following code example is that the user can start a long running task by clicking the startButton
, but the user can also cancel that long running task by clicking the cancelButton
:
let controller;
async function longTask() {
try {
blockMainThread(500);
// Yield the task using the scheduler, passing the signal from the controller
// The "inherit" option means the task will inherit the signal from its parent
await scheduler.yield({ signal: "inherit" });
// If the user cancels the task, the code below will not run
blockMainThread(500);
} catch (error) {
if (error.name === "AbortError") {
// Handle the abort
}
}
}
startButton.addEventListener("click", () => {
controller = new TaskController();
// Post the longTask to the scheduler, passing the signal from the controller
scheduler.postTask(longTask, {
signal: controller.signal,
});
});
cancelButton.addEventListener("click", () => {
if (controller) {
controller.abort();
}
});
In the previous code, take note of the following:
- The
scheduler.yield
function throws anAbortError
if the task is aborted. - A good separation of concerns is achieved by using a
TaskController
to handle the cancellation - thelongTask
function does not need to know of the mechanism used to cancel it. - Notice that the
longTask()
function does not need modification to support cancellation. TheTaskController
andscheduler.postTask
take care of this.
Here's an interactive demo to show the cancellation of the continuation. To try this feature out:
- Click 'Start work'.
- Shortly after, click 'Cancel work'.
- Observe that the task is cancelled and the output is updated.
Browser support
scheduler.yield
is currently an experimental web standard, so you need to opt in first before using it. You can either do that locally in your browser or by signing your website up to try out the new feature.
Use in your local development environment
To test scheduler.yield
locally you can set a flag in Chrome. To enable it:
- Go to
chrome://flags
. - Search for "Experimental Web Platform features" and enable it.
- Once enabled, you'll need to restart your browser for the changes to take effect.
Within your JavaScript code, you can use this check to see if scheduler.yield
is available:
if ("scheduler" in window && "yield" in scheduler) {
console.log("scheduler.yield is supported");
} else {
console.log("scheduler.yield is not supported");
}
Use in production
If you want to use this feature in production, you'll need to sign up for the Origin Trial.
Origin trials give you access to a new or experimental feature, to build functionality your users can try out for a limited time before the feature is made available to everyone.
Fallback for unsupported browsers
A polyfill is also available for browsers that don't support scheduler.yield
. You can find it on GitHub or on npm
.
If you don't need all the features of scheduler.yield
, you can consider this alternative fallback technique that involves using setTimeout
to yield control back to the browser scheduler:
async function blocksInChunks() {
// Blocks for 500ms
await blockMainThread(500);
// Yields to the browser scheduler
await new Promise((resolve) => setTimeout(resolve, 0));
// Blocks for another 500ms
await blockMainThread(500);
}
While the behaviour of setTimeout
is not exactly the same as scheduler.yield
, you should still see an improvement in your web performance metrics when you use either technique.
The exact differences between scheduler.yield
and setTimeout
are out of scope for this article, but one important takeaway is:
- With
scheduler.yield
, the continuation of work is placed at the front of the task queue. - With
setTimeout
, the continuation of work is placed at the end of the task queue.
Generally speaking, scheduler.yield
offers more predictable scheduling behaviour than setTimeout
.
Consider the following code examples, and take note of their respective outputs:
With scheduler.yield
(preferred):
// Adds to the task queue
setTimeout(() => console.log("Timeout callback"), 0);
console.log("Yielding with scheduler.yield");
await scheduler.yield();
console.log("Continuation after scheduler.yield");
scheduler.yield
Yielding with scheduler.yield
Continuation after scheduler.yield
Timeout callback
With setTimeout
(fallback):
// Adds to the task queue
setTimeout(() => console.log("Timeout callback"), 0);
console.log("Yielding with setTimeout");
await new Promise((resolve) => setTimeout(resolve, 0));
console.log("Continuation after setTimeout");
setTimeout
Yielding with setTimeout
Timeout callback
Continuation after setTimeout
In both examples, take note of when the continuation of work occurs.
How DebugBear helps monitor the impact of scheduler.yield
DebugBear is a web performance monitoring tool that helps you track the performance of your web applications. DebugBear shows you:
- Interaction to Next Paint scores from CrUX.
- Specific Interaction to Next Paint data per-page view.
- A visualisation of your Total Blocking Time metric.
- A visualisation of your CPU activity on the main thread.
When you use scheduler.yield
, you can monitor the impact on your web performance metrics:
-
Your overall Interaction to Next Paint (INP) score should improve, as the browser can respond to user input more quickly as you chunk your work and frequently yield to the browser scheduler.
Where available, we display INP data from CrUX in your DebugBear report.
-
When you use DebugBear's Real User Monitoring feature, we display INP data per-page view. You also get an component level breakdown of INP, which can help you identify which parts of your page are causing the most delay.
In the previous example, a high input delay is observed. This is because the main thread is blocked and the browser is unable to respond to user input. If you regularly yield back to the browser scheduler, you can reduce this input delay.
-
Your Total Blocking Time score may improve, as you work to better orchestrate your JavaScript work, chunk tasks into smaller pieces, and minimise the tasks themselves.
-
A DebugBear report shows you activity that was blocking the CPU. You can view this on your timeline.
Conclusion
The scheduler.yield
API helps you provide a better user experience for your users by yielding control back to the browser scheduler. This can help prevent your JavaScript code from blocking the main thread.
You should aim to to split your work into smaller chunks and yield to the browser scheduler frequently, but you should also consider where it makes sense to do so. For example, you might not want to yield control back to the browser scheduler in the middle of a critical operation.
You should consider for other scenarios. What happens when:
- The
scheduler.yield
rejects the promise and your work is not completed. - The page is closed before the
scheduler.yield
promise is resolved. - The delay between the
scheduler.yield
promise being resolved and the continuation of work is unexpectedly long.
Take note that scheduler.yield
is not a magic bullet. For example, when you yield to the browser scheduler, the browser might decide to run another task that blocks the main thread. You should also consider that interspersing your existing code with scheduler.yield
calls is not guaranteed to improve performance.
Further reading
- Interactive code demos of
scheduler.yield
- Scheduler MDN documentation
- Introducing the scheduler.yield origin trial on web.dev