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?
Published: September 30, 2022, Last updated: December 19, 2024
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 responding to 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.
In this case, saveSettings()
is triggered by a user click, and because the browser isn't able to show a response until the entire function is finished running, the result of this long task is a slow and unresponsive UI, and will be measured as a poor Interaction to Next Paint (INP).
Manually defer code execution
To make sure important user-facing tasks and UI responses happen before lower-priority tasks, you can yield to the main thread by briefly interrupting your work to give the browser opportunities to run more important tasks.
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 after five rounds of nested setTimeout()
s, the browser will start imposing a minimum 5 millisecond delay for each additional setTimeout()
.
setTimeout
also has another drawback when it comes to yielding: when you yield to the main thread by deferring code to run in a subsequent task using setTimeout
, that task gets added to the end of the queue. If there are other tasks waiting, they will run before your deferred code.
A dedicated yielding API: scheduler.yield()
scheduler.yield()
is an API specifically designed for yielding to the main thread in the browser.
It's not language-level syntax or a special construct; scheduler.yield()
is just a function that returns a Promise
that will be resolved in a future task. Any code chained to run after that Promise
is resolved (either in an explicit .then()
chain or after await
ing it in an async function) will then run in that future task.
In practice: insert an await scheduler.yield()
and the function will pause execution at that point and yield to the main thread. The execution of the rest of the function—called the continuation of the function—will be scheduled to run in a new event-loop task. When that task starts, the awaited promise will be resolved, and the function will continue executing where it left off.
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();
}
The real benefit of scheduler.yield()
over other yielding approaches, though, is that its continuation is prioritized, which means that if you yield in the middle of a task, the continuation of the current task will run before any other similar tasks are started.
This avoids code from other task sources from interrupting the order of your code's execution, such as tasks from third-party scripts.
Cross-browser support
scheduler.yield()
is not yet supported in all browsers, so a fallback is needed.
One solution is to drop the scheduler-polyfill
into your build, and then scheduler.yield()
can be used directly; the polyfill will handle falling back to other task-scheduling functions so it works similarly across browsers.
Alternatively, a less sophisticated version can be written in a few lines, using only setTimeout
wrapped in a Promise as a fallback if scheduler.yield()
isn't available.
function yieldToMain () {
if (globalThis.scheduler?.yield) {
return scheduler.yield();
}
// Fall back to yielding with setTimeout.
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
While browsers without scheduler.yield()
support won't get the prioritized continuation, they will still yield for the browser to stay responsive.
Finally, there may be cases where your code can't afford to yield to the main thread if its continuation isn't prioritized (for instance, a known-busy page where yielding risks not completing work for some time). In that case, scheduler.yield()
could be treated as a kind of progressive enhancement: yield in browsers where scheduler.yield()
is available, otherwise continue.
This can be done by both feature detecting and falling back to waiting a single microtask in a handy one-liner:
// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();
Break up long-running work with scheduler.yield()
The benefit of using any of these methods of using scheduler.yield()
is that you can await
it in any async
function.
For example, if you have an array of jobs to run that often end up adding up to a long task, you can insert yields to break up the task.
async function runJobs(jobQueue) {
for (const job of jobQueue) {
// Run the job:
job();
// Yield to the main thread:
await yieldToMain();
}
}
The continuation of runJobs()
will be prioritized, but still allow higher-priority work like visually responding to user input to run, not have to wait for the potentially long list of jobs to finish.
However, this is not an efficient use of yielding. scheduler.yield()
is quick and efficient, but it does have some overhead. If some of the jobs in jobQueue
are very short, then the overhead could quickly add up to more time spent yielding and resuming than executing the actual work.
One approach is to batch the jobs, only yielding between them if it's been long enough since the last yield. 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.
async function runJobs(jobQueue, deadline=50) {
let lastYield = performance.now();
for (const job of jobQueue) {
// Run the job:
job();
// If it's been longer than the deadline, yield to the main thread:
if (performance.now() - lastYield > deadline) {
await yieldToMain();
lastYield = performance.now();
}
}
}
The result is that the jobs are broken up to never take too long to run, but the runner only yields to the main thread about every 50 milliseconds.
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.
- Use
scheduler.yield()
(with a cross-browser fallback) to ergonomically yield and get prioritized continuations - Finally, do as little work as possible in your functions.
To learn more about scheduler.yield()
, its explicit task-scheduling relative scheduler.postTask()
, and task prioritization, see the Prioritized Task Scheduling API docs.
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.