Optimize long tasks

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?

Jeremy Wagner
Jeremy Wagner

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.

A visaulization of a task as depicted in the performance profliler of Chrome's DevTools. The task is at the top of a stack, with a click event handler, a function call, and more items beneath it. The task also includes some rendering work on the right-hand side.
A task started by a 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 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.

A long task in the performance profiler of Chrome's DevTools. The blocking portion of the task (greater than 50 milliseconds) is depicted with a pattern of red diagonal stripes.
A long task as depicted in Chrome's performance profiler. Long tasks are indicated by a red triangle in the corner of the task, with the blocking portion of the task filled in with a pattern of diagonal red stripes.

To prevent the main thread from being blocked for too long, you can break up a long task into several smaller ones.

A single long task versus the same task broken up into shorter task. The long task is one large rectangle, whereas the chunked task is five smaller boxes which are collectively the same width as the long task.
A visualization of a single long task versus that same task broken up into five shorter tasks.

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.

A depiction of how breaking up a task can facilitate a user interaction. At the top, a long task blocks an event handler from running until the task is finished. At the bottom, the chunked up task permits the event handler to run sooner than it otherwise would have.
A visualization of what happens to interactions when tasks are too long and the browser can't respond quickly enough to interactions, versus when longer tasks are broken up into smaller tasks.

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.

The saveSettings function as depicted in Chrome's performance profiler. While the top-level function calls five other functions, all the work takes place in one long task that blocks the main thread.
A single function saveSettings() that calls five functions. The work is run as part of one long monolithic 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.

The same saveSettings function depicted in Chrome's performance profiler, only with yielding. The result is the once-monolithic task is now broken up into five separate tasks—one for each function.
The saveSettings() function now executes its child functions as 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.

Browser Support

  • Chrome: 94.
  • Edge: 94.
  • Firefox: behind a flag.
  • Safari: not supported.

Source

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 no priority 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.

The saveSettings function as depicted in Chrome's performance profiler, but using postTask. postTask splits up each function saveSettings runs, and prioritizes them such that a user interaction has a chance to run without being blocked.
When saveSettings() is run, the function schedules the individual functions using postTask(). The critical user-facing work is scheduled at high priority, while work the user doesn't know about is scheduled to run in the background. This allows for user interactions to execute more quickly, as the work is both broken up and prioritized appropriately.

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

Browser Support

  • Chrome: 129.
  • Edge: 129.
  • Firefox: not supported.
  • Safari: not supported.

Source

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().

Three diagrams depicting tasks without yielding, yielding, and with yielding and continuation. Without yielding, there are long tasks. With yielding, there are more tasks that are shorter, but may be interrupted by other unrelated tasks. With yielding and continuation, there are more tasks that are shorter, but their order of execution is preserved.
When you use scheduler.yield(), task execution picks up where it left off even after the yield point.

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()

Browser Support

  • Chrome: 87.
  • Edge: 87.
  • Firefox: not supported.
  • Safari: not supported.

Source

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 return false 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() and scheduler.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.