Optimize long tasks

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.

A screenshot of a task 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 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.

A long task in the performance profiler of Chrome's DevTools. The blocking portion of the task (greater than 50 milliseconds) is marked by red diagonal stripes.
A long task shown 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 this, divide each long task into smaller tasks that each take less time to run. This is called breaking up long tasks.

A single
    long task versus the same task broken up into shorter tasks. The long task
    is one large rectangle, and the chunked task is five smaller boxes whose
    length adds up to the length of the long task.
A visualization of a single long task versus that same task broken up into five shorter 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.

Breaking up a
    task can facilitate user interaction. At the top, a long task blocks an
    event handler from running until the task is finished. At the bottom, the
    chunked task lets the event handler run sooner than it otherwise would have.
When tasks are too long, the browser can't respond quickly enough to interactions. Breaking up tasks lets those interactions happen faster.

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();
}
The saveSettings function shown 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.

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.

The same
    saveSettings function in Chrome's performance profiler, now with yielding.
    The 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

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.

Browser Support

  • 94
  • 94
  • x

Source

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

The
    saveSettings function shown in Chrome's performance profiler, but using
    postTask. postTask splits up each function saveSettings runs, and
    prioritizes them so that a user interaction can run without being blocked.
When saveSettings() runs, the function schedules the individual function calls 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, because the work is both broken up and prioritized appropriately.

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

Three
    diagrams showing tasks without yielding, with yielding, and with yielding
    and continuation. Without yielding, there are long tasks. With yielding,
    there are more tasks that are shorter, but can be interrupted by other
    unrelated tasks. With yielding and continuation, the shorter tasks' 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 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() and scheduler.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.