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? {what-is-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.

Yield only when necessary

You can use isInputPending() to make your code yield only when the user tries to interact with the page. It returns true if the user is trying to interact, and false otherwise.

Combining isInputPending() with yieldToMain() tells the browser to stop any other ongoing tasks so it can respond to user interactions quickly. This can help make your page more responsive:

async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  while (tasks.length > 0) {
    // Yield to a pending user input:
    if (navigator.scheduling.isInputPending()) {
      // There's a pending user input. Yield here:
      await yieldToMain();
    } else {
      // Shift the task out of the queue:
      const task = tasks.shift();

      // Run the task:
      task();
    }
  }
}

While saveSettings() runs, it loops over the tasks in the queue. If isInputPending() returns true during the loop, saveSettings() calls yieldToMain() so the browser can handle the user input. Otherwise, it shifts the next task off the front of the queue and runs it continuously, then repeats until no more tasks are left.

The saveSettings
    function running in Chrome's performance profiler. The resulting task blocks
    the main thread until isInputPending returns true, at which point, the task
    yields to the main thread.
saveSettings() runs a task queue for five tasks, but the user clicked to open a menu while the second work item was running. isInputPending() yields to the main thread to handle the interaction, then resumes running the rest of the tasks.

Even if you use isInputPending(), it's still important to limit the amount of work each function does. This is because it can take time for the operating system to tell the browser about user interactions, so by the time isInputPending() returns true, another long task might already have started.

Another way to use isInputPending(), especially if you want to provide a fallback for browsers that don't support it, is to use a time-based approach along with the optional chaining operator:

async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  let deadline = performance.now() + 50;

  while (tasks.length > 0) {
    // Optional chaining operator used here helps to avoid
    // errors in browsers that don't support `isInputPending`:
    if (navigator.scheduling?.isInputPending() || performance.now() >= deadline) {
      // There's a pending user input, or the
      // deadline has been reached. Yield here:
      await yieldToMain();

      // Extend the deadline:
      deadline = performance.now() + 50;

      // Stop the execution of the current loop and
      // move onto the next iteration:
      continue;
    }

    // Shift the task out of the queue:
    const task = tasks.shift();

    // Run the task:
    task();
  }
}

This creates a fallback for browsers that don't support isInputPending() by using a time-based approach that sets and adjusts a deadline so that work is broken up where necessary, either by yielding to user input, or by a certain point in time.

Gaps in current APIs

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.

Fortunately, there is a dedicated scheduler API that is currently in development that addresses these problems.

A dedicated scheduler API

The scheduler API currently offers the postTask() function which, at the time of writing, is available in Chromium browsers, and in Firefox behind a flag. postTask() 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 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 in such a way that browser-prioritized tasks—such as 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 via scheduler.yield

One proposed part of the scheduler API is scheduler.yield, an API specifically designed for yielding to the main thread in the browser which is currently available to try as an origin trial. Its use resembles the yieldToMain() function demonstrated earlier in this article:

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

You'll note that the code above is largely familiar, but instead of using yieldToMain(), you call and await scheduler.yield() instead.

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 will continue in the same order after the yield point. This avoids code from third-party scripts from usurping the order of your code's execution.

Conclusion

Managing tasks is challenging, but doing so helps your page respond more quickly to user interactions. There's no one single piece of advice for managing and prioritizing tasks. Rather, it's 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 isInputPending() to yield to the main thread when the user is trying to interact with the page.
  • 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. 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 document.

Thumbnail image sourced from Unsplash, courtesy of Amirali Mirhashemian.