When a Task Isn’t Just a Task

Most guidance on keeping JavaScript responsive reduces to two commands: don’t block the main thread, and break up long tasks. Both are correct, but they rarely tell you how. Shipping less JavaScript helps, but it does not automatically mean interactions will feel instant.

Understanding task optimization starts with a basic definition: a task is any discrete chunk of work the browser performs — rendering, HTML and CSS parsing, JavaScript execution, and other work you don’t directly control. JavaScript is usually the largest contributor.

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.

JavaScript-related tasks affect performance in two key moments:

  • During startup, parsing and compiling a downloaded JavaScript file queues tasks that must run before execution.
  • Later, tasks queue from event handlers, JavaScript-driven animations, and background work like analytics.

Except for work delegated to web workers or similar APIs, all of this runs on the main thread.

The Main Thread and the 50-Millisecond Threshold

The main thread processes one task at a time. Any single task running longer than 50 milliseconds qualifies as a long task; its blocking period is the total duration minus those first 50 milliseconds.

Even short tasks block interactions while running, but users generally won’t notice if tasks stay brief. When long tasks pile up, the interface feels unresponsive — and if the main thread is blocked long enough, interactions can appear broken.

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.

The fix is to split a long task into 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.

Why does this work? Once a task is broken up, the browser can service higher-priority work — including user input — in the gaps. The remaining tasks still run to completion afterward, so the work you queued gets done; it just no longer holds the interface hostage.

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.

In the first scenario shown above, an event handler waits for one long task, and the interaction lags. In the second, the handler starts sooner and the interaction feels instant.

Good Architecture Isn't Enough

Conventional software advice says to split work into small functions:

function saveSettings () {
  validateForm();
  showSpinner();
  saveToDatabase();
  updateUI();
  sendAnalytics();
}

Here, a saveSettings() function calls five smaller functions: form validation, spinner display, backend request, UI update, and analytics logging. Conceptually, that structure is clean and maintainable — you can navigate to any function and debug it independently.

The catch is that JavaScript does not execute each function as a separate task. These five functions all run inside the saveSettings() function, which gets scheduled as one task inline.

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 makes it so the user-visible result of running the function is not visible until all are complete.
A single function 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.

Even one of those sub-functions can take up 50 milliseconds or more; several in sequence can easily exceed that, especially on lower-end devices. If a click triggers saveSettings(), the browser cannot respond until the entire call stack finishes. That yields a slow UI, measured as poor Interaction to Next Paint (INP).

Manually Yielding via setTimeout()

One time-honored method is setTimeout(). Passing a function to it — even with a timeout of 0 — defers that function into a separate task:

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 a yield, and it suits a short series of functions requiring sequential execution.

The approach breaks down without that structure — for instance, a loop processing a large dataset where each iteration is individually fast, but the collection runs too long:

function processData () {
  for (const item of largeDataArray) {
    // Process the individual item here.
  }
}

Adding yielding inside the loop gets awkward, and after five nestings, browsers enforce a minimum 5-millisecond delay for subsequent setTimeout() calls — an unforgiving tax on a loop with many iterations.

There’s a second drawback: when a setTimeout() callback yields, its new task is appended to the end of the task queue. Any previously queued work — including tasks from third-party scripts — runs before your code resumes. That can push your continuation far into the future.

scheduler.yield(): A Purpose-Built Yield

Browser Support data confirms that scheduler.yield() shipped in Chrome and Edge 129, with Firefox 142 following in an experimental capacity.

Unlike setTimeout, which is a general-purpose timeout function used to approximate yielding, scheduler.yield() was designed for this exact job. It’s a plain API: it returns a Promise that resolves in a future task. Code chained after it — via .then() or await inside an async function — runs in that continuation task.

Usage is just inserting an await:

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

The function pauses, yields to the main thread, and its continuation resumes in a fresh event-loop task.

The saveSettings function as depicted in Chrome's performance profiler, now broken up into two tasks. The first task calls two functions, then yields, allowing layout and paint work to happen and give the user a visible response. As a result, the click event is finished in a much quicker 64 milliseconds. The second task calls the last three functions.
The execution of the function 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.

What differentiates scheduler.yield() is task prioritization. When you yield mid-task, its continuation runs ahead of other tasks, preserving the original ordering of your code. No third-party script can jump your queued continuation by adding its own tasks to the queue’s tail.

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(), the continuation picks up where it left off before moving on to other tasks.

A Fallback Story

scheduler.yield() isn’t yet universal. You have a few options depending on how much you want from unsupported browsers:

  • Polyfill: Add the scheduler-polyfill package, which falls back on other scheduling APIs.
  • Minimal fallback: Some three lines in your own code, wrapping setTimeout in a Promise if scheduler.yield() is absent.
  • Progressive enhancement: In browsers without support, keep blocked and avoid yielding entirely. Use feature detection:
function yieldToMain () {
  if (globalThis.scheduler?.yield) {
    return scheduler.yield();
  }

  // Fall back to yielding with setTimeout.
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}
// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();

The second pattern forfeits responsiveness in older browsers but preserves ordering. It says: yield where a prioritized continuation exists, otherwise just continue the task briefly.

Yielding On a Schedule

Being able to await scheduler.yield() in any async function makes it easy to sprinkle throughout long-running processes:

async function runJobs(jobQueue) {
  for (const job of jobQueue) {
    // Run the job:
    job();

    // Yield to the main thread:
    await yieldToMain();
  }
}

Here, runJobs() yields every iteration, letting responsive work step in. Yet scheduler.yield() has overhead — yielding thousands of times for very short jobs wastes more time than it saves.

Yield once in a while instead. Batch the jobs and yield only when last contact with the main thread exceeded some deadline — typically 50 milliseconds, a middle ground maximizing responsiveness without treating a tiny job as a need for yielding:

async function runJobs(jobQueue, deadline=50) {
  let lastYield = performance.now();

  for (const job of jobQueue) {
    // Run the job:
    job();

    // If it's been longer than the deadline, yield to the main thread:
    if (performance.now() - lastYield > deadline) {
      await yieldToMain();
      lastYield = performance.now();
    }
  }
}

That approach keeps individual tasks under the long-task threshold while only paying the yield cost about every 50 milliseconds.

A series of job functions, shown in the Chrome DevTools performance panel, with their execution broken up over multiple tasks
Jobs batched into multiple tasks.

Rethink isInputPending()

The isInputPending() API was designed to let JavaScript check whether a user is trying to interact with the page, yielding only when an input is pending and otherwise continuing to run without falling to the back of the task queue. For sites that otherwise would not yield back to the main thread, it could offer significant performance gains.

However, our understanding of yielding has evolved since that API shipped, particularly with the introduction of INP. The current recommendation is to not use this API and instead yield unconditionally. Several issues drive this shift:

  • isInputPending() can incorrectly return false even when a user has interacted in certain scenarios.
  • Input is not the only reason to yield. Animations and other regular interface updates are just as important for a responsive page.
  • More comprehensive yielding mechanisms have arrived—such as scheduler.postTask() and scheduler.yield()—which address yielding concerns more fully.

Putting task management together

Managing tasks is difficult, but it directly determines how quickly your page responds to user interaction. There is no single correct approach; a combination of techniques works best. Keep these priorities in mind:

  • Yield to the main thread for critical, user-facing work.
  • Use scheduler.yield()—with a cross-browser fallback—to yield ergonomically and get prioritized continuations.
  • Do as little work as possible inside your functions.

For more detail on scheduler.yield(), its explicit scheduling counterpart scheduler.postTask(), and task prioritization, refer to the Prioritized Task Scheduling API documentation.

Using one or more of these tools, you can structure your application's work to prioritize the user's needs while still completing less critical tasks. The result is a more responsive and more pleasant user experience.

Thanks to Philip Walton for his technical review of this guide.