Articles

How to Break Up Long Tasks in JavaScript

Long tasks block the main thread for 50ms or more and make pages feel frozen. How to split them with yielding, scheduler.yield(), postTask, and workers.

Takina Takina · · 6 min read
A black stopwatch

A long task is any piece of work that keeps the browser’s main thread busy for 50 milliseconds or more without a break. While it runs, the browser can’t respond to clicks, keypresses, or scrolling, and it can’t paint. To fix one, you split the work into smaller chunks and yield to the main thread between them. That gives the browser regular chances to handle input and render a frame before your code carries on.

This guide covers why long tasks hurt, how to find them, and the main ways to break them up, from plain setTimeout to scheduler.yield() and web workers.

Why long tasks make pages feel slow

The browser runs JavaScript, style calculation, layout, and paint on a single main thread. As the JavaScript event loop explainer describes, it runs one task at a time to completion. A task can’t be interrupted partway through. If a click arrives while a 300ms task is running, the click handler waits in the queue until that task finishes.

This is the main cause of a poor Interaction to Next Paint (INP) score. INP measures the time from an input to the next frame the user sees, and a long task already running when the user clicks adds directly to that time. The 50ms threshold comes from the RAIL guidance: if no task runs longer than 50ms, the browser can respond to new input within roughly 100ms, which still feels instant.

Common sources of long tasks include:

  • Processing a large array or JSON payload in one loop
  • Rendering a long list or a big component tree in one pass
  • Running several unrelated handlers inside one event callback
  • Hydrating a large server-rendered page all at once
  • Running third-party scripts during startup

How to find long tasks

Before changing anything, confirm where the time goes. In the browser DevTools Performance panel, long tasks show up as gray task blocks with a red-striped corner. Expand one to see which functions used the time.

In the field, you can watch for them with a PerformanceObserver:

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log("Long task", Math.round(entry.duration), "ms");
  }
}).observe({ type: "longtask", buffered: true });

Chromium also supports the more detailed long-animation-frame entry type, which attributes slow frames to specific scripts. Either way, the aim is to know which function to split before you start splitting.

Yielding with setTimeout

The oldest way to yield is to schedule the rest of the work as a new task with setTimeout. Wrapping it in a promise keeps the calling code readable with async/await:

function yieldToMain() {
  return new Promise((resolve) => setTimeout(resolve, 0));
}

async function processItems(items) {
  for (const item of items) {
    process(item);
    await yieldToMain();
  }
}

Each await yieldToMain() ends the current task. While the rest of your work waits in the task queue, the browser is free to handle any pending input or paint a frame.

This approach has two drawbacks. The remaining work goes to the back of the task queue, so other scripts’ tasks can run first and delay your work. Browsers also clamp nested timers to a minimum delay of about 4ms after several levels of nesting, which adds up across many chunks. It’s also important to understand that awaiting a resolved promise on its own does not yield. Promise callbacks are microtasks, and as the microtasks vs. macrotasks breakdown explains, the browser drains every microtask before it renders.

Yield only when needed

Yielding after every item adds overhead. A better pattern is to do as much work as fits in a time budget, then yield:

async function processItems(items) {
  let deadline = performance.now() + 50;
  for (const item of items) {
    process(item);
    if (performance.now() >= deadline) {
      await yieldToMain();
      deadline = performance.now() + 50;
    }
  }
}

This keeps each chunk under the long-task threshold while keeping the number of task switches low. A budget of 50ms or less is a reasonable starting point. If interactions still feel slow, lower it.

scheduler.yield(): yielding without losing your place

scheduler.yield() is an API built for this exact pattern. It returns a promise that resolves in a new task, so the browser gets its chance to handle input and paint. The difference from setTimeout is that the continuation goes to the front of the queue rather than the back. Your work picks up before other queued tasks of the same priority, so yielding doesn’t cost you your place.

Browser support is still uneven, so feature-detect and fall back:

function yieldToMain() {
  if (globalThis.scheduler?.yield) {
    return scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}

You can swap this helper into the loops above without other changes.

scheduler.postTask(): prioritized work

The related scheduler.postTask() lets you queue a callback with an explicit priority: "user-blocking", "user-visible" (the default), or "background". This helps when a single event triggers both urgent and non-urgent work:

button.addEventListener("click", () => {
  updateButtonState();              // urgent: visible feedback
  scheduler.postTask(sendAnalytics, { priority: "background" });
  scheduler.postTask(saveDraft, { priority: "user-visible" });
});

The handler only does what the user needs to see right away. Everything else runs later as separate tasks, so the next frame isn’t held up by analytics. Tasks can also be cancelled with an AbortController-style TaskController when the work is no longer needed.

Comparing the yielding options

ApproachYields to input/paintContinuation positionPriority controlSupport
await Promise.resolve()No (microtask)ImmediatelyNoUniversal
setTimeout(fn, 0)YesBack of the queueNoUniversal
requestIdleCallbackYesWhen the browser is idleNoBroad, not universal
scheduler.yield()YesFront of the queueInherits caller’s priorityPartial; feature-detect
scheduler.postTask()YesBy priorityYesPartial; feature-detect

requestIdleCallback is a good fit for work that can wait indefinitely, such as prefetching or warming a cache. It’s a poor fit for work the user is waiting on, because idle periods may not come for a while on a busy page.

Move heavy work off the main thread

Yielding keeps the page responsive, but the work still competes with rendering for the same thread. When a job is CPU-heavy and doesn’t need the DOM, such as parsing a large file, image processing, diffing, or search indexing, move it to a web worker. The worker runs on its own thread and posts results back with postMessage. The main thread then only spends time applying those results.

Workers cost something too. Data copied between threads has to be serialized, and workers can’t touch the DOM. They work best for large, self-contained computations, not for many small DOM updates.

Do less work in the first place

Splitting work is not a substitute for reducing it. Before you start chunking, check whether the work needs to happen at all, or whether it needs to happen now:

  • Debounce or throttle high-frequency events such as input, scroll, and resize, as covered in debounce vs. throttle.
  • Virtualize long lists so only visible rows are rendered.
  • Defer non-critical scripts and component hydration until after the first interaction or when they scroll into view.
  • Memoize expensive results that are recomputed with the same inputs.

The takeaway

A long task is anything that holds the main thread for 50ms or more, and it delays every interaction that arrives in the meantime. Find these tasks with DevTools or a PerformanceObserver. Split the work into chunks that fit a time budget, and yield between chunks with scheduler.yield(), falling back to setTimeout where it isn’t supported. Use postTask priorities to separate urgent work from background work, and move heavy computation into a web worker. Most importantly, keep each event handler limited to what the user needs to see immediately.

Takina Takina · · 5 min read

Microtasks vs Macrotasks in JavaScript, Explained

Microtasks (promise callbacks) run before the next macrotask (timers, events). How the two queues are ordered, and why it matters for your code.

#JavaScript #Web Development #Performance
Takina Takina · · 5 min read

OffscreenCanvas API Explained

OffscreenCanvas lets you render canvas graphics off the main thread, in a web worker, so heavy drawing work stops blocking scrolling, input, and animation.

#JavaScript #Web Development #Performance
Takina Takina · · 3 min read

What Is a SharedWorker? Explained

A SharedWorker runs one script shared across every tab and window of the same origin — a single background thread they can all connect to and message.

#JavaScript #Web Development #Performance