Optimize long tasks Javascript

Optimize long tasks Javascript

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?

Task is the task that the browser does. That work includes rendering, parsing HTML and CSS, running JavaScript, and other kinds of tasks that you may not have direct control over. Of all of these, the JavaScript you write is probably the biggest source of task.

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.

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

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.

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.

Solution with Built-in yield with continuation using the upcoming scheduler.yield() API

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

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.

Using scheduler.postTask() with priority: 'user-blocking' also has a high likelihood of continuation due to the high user-blocking priority, so this approach could be used as an alternative in the meantime.

Using setTimeout() (or scheduler.postTask() with priority: 'user-visibile' or no explicit priority) schedules the task at the back of the queue and so lets other pending tasks run before the continuation.