Commonly available advice for making your JavaScript apps faster often includes "Don't block the main thread" and "Break up your long tasks." This page breaks down what that advice means, and why optimizing tasks in JavaScript is important.
What is a task?
A task is any discrete piece of work that the browser does. This includes rendering, parsing HTML and CSS, running the JavaScript code you write, and other things you might not have direct control over. Your pages' JavaScript is a major source of browser tasks.
Tasks impact performance in several ways. For example, when a browser downloads a JavaScript file during startup, it queues tasks to parse and compile that JavaScript so it can be executed. Later in the page lifecycle, other tasks begin when your JavaScript does work such as driving interactions through event handlers, JavaScript-driven animations, and background activity such as analytics collection. All of this, 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 counts as a long task. If the user tries to interact with the page during a long task or a rendering update, the browser must wait to handle that interaction, causing latency.
To prevent this, divide each long task into smaller tasks that each take less time to run. This is called breaking up long tasks.
Breaking tasks up gives the browser more opportunities to respond to higher-priority work, including user interactions, between other tasks. This lets interactions happen much faster, where a user might otherwise have noticed lag while the browser waited for a long task to finish.
Task management strategies
JavaScript treats each function as a single task, because it uses a run-to-completion model of task execution. This means that a function that calls multiple other functions, like the following example, must run until all the called functions complete, which slows down the browser:
function saveSettings () { //This is a long task.
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
If your code contains functions that call multiple methods, split it up into multiple functions. Not only does this give the browser more opportunities to respond to interaction, but it also makes your code easier to read, maintain, and write tests for. The following sections walk through some strategies for breaking up long functions and prioritizing the tasks that make them up.
Manually defer code execution
You can postpone the execution of some tasks by passing the relevant function to
setTimeout()
. This works 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 works best for a series of functions that needs to run in order. Code
that's organized differently needs a different approach. The next example is
a function that processes a large amount of data using a loop. The larger the
dataset is, the longer this takes, and there's not necessarily a good place in
the loop to put a setTimeout()
:
function processData () {
for (const item of largeDataArray) {
// Process the individual item here.
}
}
Fortunately, there are a few other APIs that let you defer code execution to a
later task. We recommend using postMessage()
for faster timeouts.
You can also break up work using requestIdleCallback()
, but it schedules tasks
at the lowest priority and only during browser idle time, meaning that if the
main thread is especially busy, tasks scheduled with requestIdleCallback()
might never get to run.
Use async
/await
to create yield points
To make sure important user-facing tasks happen before lower-priority tasks, yield to the main thread by briefly interrupting the task queue to give the browser opportunities to run more important tasks.
The clearest way to do this involves a Promise
that resolves with a call to
setTimeout()
:
function yieldToMain () {
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
In the saveSettings()
function, you can yield to the main thread after each
step if you await
the yieldToMain()
function after each function call. This
effectively breaks up your long task into multiple tasks:
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();
}
}
Key point: You don't have to yield after every function call. For example, if you run two functions that result in critical updates to the user interface, you probably don't want to yield in between them. If you can, let that work run first, then consider yielding between functions that do background or less critical work that the user doesn't see.
A dedicated scheduler API
The APIs mentioned so far can help you break up tasks, but they have a significant downside: when you yield to the main thread by deferring code to run in a later task, that code gets added to the end of the task queue.
If you control all the code on your page, you can create your own scheduler to prioritize tasks. However, third-party scripts won't use your scheduler, so you can't really prioritize work in that case. You can only break it up or yield to user interactions.
The scheduler API offers the postTask()
function, which allows for
finer-grained scheduling of tasks and can help the browser prioritize work so
that low priority tasks yield to the main thread. postTask()
uses promises
and accepts a priority
setting.
The postTask()
API has three priorities available:
'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.
The following example code uses the postTask()
API 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 so that browser-prioritized tasks like user interactions can work their way in.
You can also instantiate different TaskController
objects that share
priorities between tasks, including the ability to change priorities for
different TaskController
instances as needed.
Built-in yield with continuation using the upcoming scheduler.yield()
API
Key point: For a more detailed explanation of scheduler.yield()
, read about
its origin trial
(since concluded), as well as its explainer.
One proposed addition to the scheduler API is scheduler.yield()
, an API
specifically designed for yielding to the main thread in the browser. Its use
resembles the yieldToMain()
function demonstrated earlier on this page:
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 continue in the
same order after the yield point. This prevents third-party scripts from
taking control of the order your code executes in.
Using scheduler.postTask()
with priority: 'user-blocking'
also has a high
likelihood of continuation because of the high user-blocking
priority, so you
can use that as an alternative until scheduler.yield()
becomes more widely
available.
Using setTimeout()
(or scheduler.postTask()
with priority: 'user-visible'
or no explicit priority
) schedules the task at the back of the queue, letting
other pending tasks run before the continuation.
Yield on input with isInputPending()
Browser Support
- 87
- 87
- x
- x
The isInputPending()
API provides a way of checking if a user has attempted to interact with a page
and yield only 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 understanding of yielding has improved, especially after the introduction of INP. We no longer recommend using this API, and instead recommend yielding regardless of whether input is pending or not. This change in recommendations is for a number of reasons:
- The API might incorrectly return
false
in some cases where a user has interacted. - 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 like
scheduler.postTask()
andscheduler.yield()
have since been introduced to address yielding concerns.
Conclusion
Managing tasks is challenging, but doing so helps your page respond more quickly to user interactions. There are a variety of techniques for managing and prioritizing tasks depending on your use case. 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.
- Consider experimenting with
scheduler.yield()
. - Prioritize tasks with
postTask()
. - 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. This improves the user experience by making it more responsive and more enjoyable to use.
Special thanks to Philip Walton for his technical vetting of this document.
Thumbnail image sourced from Unsplash, courtesy of Amirali Mirhashemian.