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.
click event handler in, shown in Chrome DevTools' performance profiler.
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.
saveSettings() that calls five functions. The work is run as part of one long monolithic task, blocking any visual response until all five functions are complete.
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 awaiting 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();
}
saveSettings() is now split over two tasks. As a result, layout and paint can run between the tasks, giving the user a quicker visual response, as measured by the now much shorter pointer interaction.
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.
scheduler.yield(), the continuation picks up where it left off before moving on to other tasks.
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.