You've been told "don't block the main thread" and "break up your long tasks", but what does it mean to do those things?
Common advice for keeping JavaScript apps fast tends to boil down to the following advice:
- "Don't block the main thread."
- "Break up your long tasks."
This is great advice, but what work does it involve? Shipping less JavaScript is good, but does that automatically equate to more responsive user interfaces? Maybe, but maybe not.
To understand how to optimize tasks in JavaScript, you first need to know what tasks are, and how the browser handles them.
What is a task?
A task is any discrete piece of work that the browser does. That work includes rendering, parsing HTML and CSS, running JavaScript, and other types of work you may not have direct control over. Of all of this, the JavaScript you write is perhaps the largest source of tasks.
Tasks associated with JavaScript impact performance in a couple of ways:
- When a browser downloads a JavaScript file during startup, it queues tasks to parse and compile that JavaScript so it can be executed later.
- At other times during the life of the page, tasks are queued when JavaScript does work such as driving interactions through event handlers, JavaScript-driven animations, and background activity such as analytics collection.
All of this stuff—with the exception of web workers and similar APIs—happens on the main thread.
What is the main thread?
The main thread is where most tasks run in the browser, and where almost all JavaScript you write is executed.
The main thread can only process one task at a time. Any task that takes longer than 50 milliseconds is a long task. For tasks that exceed 50 milliseconds, the task's total time minus 50 milliseconds is known as the task's blocking period.
The browser blocks interactions from occurring while a task of any length is running, but this is not perceptible to the user as long as tasks don't run for too long. When a user attempts to interact with a page when there are many long tasks, however, the user interface will feel unresponsive, and possibly even broken if the main thread is blocked for very long periods of time.
To prevent the main thread from being blocked for too long, you can break up a long task into several smaller ones.
This matters because when tasks are broken up, the browser can respond to higher-priority work much sooner—including user interactions. Afterward, remaining tasks then run to completion, ensuring the work you initially queued up gets done.
At the top of the preceding figure, an event handler queued up by a user interaction had to wait for a single long task before it could begin, This delays the interaction from taking place. In this scenario, the user might have noticed lag. At the bottom, the event handler can begin to run sooner, and the interaction might have felt instant.
Now that you know why it's important to break up tasks, you can learn how to do so in JavaScript.
Task management strategies
A common piece of advice in software architecture is to break up your work into smaller functions:
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
In this example, there's a function named saveSettings()
that calls five functions to validate a form, show a spinner, send data to the application backend, update the user interface, and send analytics.
Conceptually, saveSettings()
is well-architected. If you need to debug one of these functions, you can traverse the project tree to figure out what each function does. Breaking up work like this makes projects easier to navigate and maintain.
A potential problem here, though, is that JavaScript doesn't run each of these functions as separate tasks because they are executed within the saveSettings()
function. This means that all five functions will run as one task.
In the best case scenario, even just one of those functions can contribute 50 milliseconds or more to the task's total length. In the worst case, more of those tasks can run much longer—especially on resource-constrained devices.
Manually defer code execution
One method developers have used to break up tasks into smaller ones involves setTimeout()
. With this technique, you pass the function to setTimeout()
. This postpones execution of the callback into a separate task, even if you specify a timeout of 0
.
function saveSettings () {
// Do critical work that is user-visible:
validateForm();
showSpinner();
updateUI();
// Defer work that isn't user-visible to a separate task:
setTimeout(() => {
saveToDatabase();
sendAnalytics();
}, 0);
}
This is known as yielding, and it works best for a series of functions that need to run sequentially.
However, your code may not always be organized this way. For example, you could have a large amount of data that needs to be processed in a loop, and that task could take a very long time if there are many iterations.
function processData () {
for (const item of largeDataArray) {
// Process the individual item here.
}
}
Using setTimeout()
here is problematic because of developer ergonomics, and the entire array of data could take a very long time to process, even if every individual iteration runs quickly. It all adds up, and setTimeout()
isn't the right tool for the job—at least not when used this way.
Use async
/await
to create yield points
To make sure important user-facing tasks happen before lower-priority tasks, you can yield to the main thread by briefly interrupting the task queue to give the browser opportunities to run more important tasks.
As explained earlier, setTimeout
can be used to yield to the main thread. For convenience and better readability, though, you can call setTimeout
within a Promise
and pass its resolve
method as the callback.
function yieldToMain () {
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
The benefit of the yieldToMain()
function is that you can await
it in any async
function. Building off the previous example, you could create an array of functions to run, and yield to the main thread after each one runs:
async function saveSettings () {
// Create an array of functions to run:
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
]
// Loop over the tasks:
while (tasks.length > 0) {
// Shift the first task off the tasks array:
const task = tasks.shift();
// Run the task:
task();
// Yield to the main thread:
await yieldToMain();
}
}
The result is that the once-monolithic task is now broken up into separate tasks.
A dedicated scheduler API
setTimeout
is an effective way to break up tasks, but it can have a drawback: when you yield to the main thread by deferring code to run in a subsequent task, that task gets added to the end of the queue.
If you control all the code on your page, it's possible to create your own scheduler with the ability to prioritize tasks—but third-party scripts won't use your scheduler. In effect, you're not able to prioritize work in such environments. You can only chunk it up, or explicitly yield to user interactions.
The scheduler API offers the postTask()
function which allows for finer-grained scheduling of tasks, and is one way to help the browser prioritize work so that low priority tasks yield to the main thread. postTask()
uses promises, and accepts one of three priority
settings:
'background'
for the lowest priority tasks.'user-visible'
for medium priority tasks. This is the default if nopriority
is set.'user-blocking'
for critical tasks that need to run at high priority.
Take the following code as an example, where the postTask()
API is used to run three tasks at the highest possible priority, and the remaining two tasks at the lowest possible priority.
function saveSettings () {
// Validate the form at high priority
scheduler.postTask(validateForm, {priority: 'user-blocking'});
// Show the spinner at high priority:
scheduler.postTask(showSpinner, {priority: 'user-blocking'});
// Update the database in the background:
scheduler.postTask(saveToDatabase, {priority: 'background'});
// Update the user interface at high priority:
scheduler.postTask(updateUI, {priority: 'user-blocking'});
// Send analytics data in the background:
scheduler.postTask(sendAnalytics, {priority: 'background'});
};
Here, the priority of tasks is scheduled in such a way that browser-prioritized tasks—such as user interactions—can work their way in between as needed.
This is a simplistic example of how postTask()
can be used. It's possible to instantiate different TaskController
objects that can share priorities between tasks, including the ability to change priorities for different TaskController
instances as needed.
Built-in yield with continuation using the scheduler.yield()
API
scheduler.yield()
is an API specifically designed for yielding to the main thread in the browser. Its use resembles the yieldToMain()
function demonstrated earlier in this guide:
async function saveSettings () {
// Create an array of functions to run:
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
]
// Loop over the tasks:
while (tasks.length > 0) {
// Shift the first task off the tasks array:
const task = tasks.shift();
// Run the task:
task();
// Yield to the main thread with the scheduler
// API's own yielding mechanism:
await scheduler.yield();
}
}
This code is largely familiar, but instead of using yieldToMain()
, it uses
await scheduler.yield()
.
The benefit of scheduler.yield()
is continuation, which means that if you yield in the middle of a set of tasks, the other scheduled tasks will continue in the same order after the yield point. This avoids code from third-party scripts from interrupting the order of your code's execution.
Don't use isInputPending()
The isInputPending()
API provides a way of checking if a user has attempted to interact with a page and only yield if an input is pending.
This lets JavaScript continue if no inputs are pending, instead of yielding and ending up at the back of the task queue. This can result in impressive performance improvements, as detailed in the Intent to Ship, for sites that might otherwise not yield back to the main thread.
However, since the launch of that API, our understand of yielding has increased, particularly with the introduction of INP. We no longer recommend using this API, and instead recommend yielding regardless of whether input is pending or not for a number of reasons:
isInputPending()
may incorrectly returnfalse
despite a user having interacted in some circumstances.- Input isn't the only case where tasks should yield. Animations and other regular user interface updates can be equally important to providing a responsive web page.
- More comprehensive yielding APIs have since been introduced which address yielding concerns, such as
scheduler.postTask()
andscheduler.yield()
.
Conclusion
Managing tasks is challenging, but doing so ensures that your page responds more quickly to user interactions. There's no one single piece of advice for managing and prioritizing tasks, but rather a number of different techniques. To reiterate, these are the main things you'll want to consider when managing tasks:
- Yield to the main thread for critical, user-facing tasks.
- Prioritize tasks with
postTask()
. - Consider experimenting with
scheduler.yield()
. - Finally, do as little work as possible in your functions.
With one or more of these tools, you should be able to structure the work in your application so that it prioritizes the user's needs, while ensuring that less critical work still gets done. That's going to create a better user experience which is more responsive and more enjoyable to use.
Special thanks to Philip Walton for his technical vetting of this guide.
Thumbnail image sourced from Unsplash, courtesy of Amirali Mirhashemian.