RenderloriaRender · Measure · Refine Request a rendering review

Guide

Breaking up long tasks with scheduler.yield

A long task keeps the browser from responding and painting until it finishes. Splitting work and yielding between the pieces is the most reliable way to keep an app responsive. This guide explains how, with scheduler.yield and fallbacks.

What makes a task long

The browser runs JavaScript, style calculation, layout and paint on a single main thread, one task at a time. A task that runs for more than 50 milliseconds is a long task. While it runs, the browser cannot respond to user input or update the screen, so the page feels laggy (source 1).

Long tasks come from ordinary code: a click handler that filters a big array and re-renders a list, a framework hydrating a page in one pass, a JSON parse of a large response, or a third-party script initializing. None of these is wrong in itself. The problem is doing all of it in one uninterrupted block.

Why yielding helps

Yielding means ending the current task deliberately and scheduling the rest of the work in a new one. Between the two, the browser gets a chance to handle any pending input and paint a frame. web.dev summarizes the idea: yielding creates opportunities for critical work to run sooner (source 1).

For Interaction to Next Paint, yielding helps in two ways. Work that is already running when a user taps finishes its current piece sooner, which shortens input delay. And inside your own handler, doing the visible update first and yielding before the rest lets the next frame paint earlier, which shortens processing and presentation time for that interaction (source 2).

The old way: setTimeout

The classic yield is a zero-delay timeout wrapped in a promise:

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

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

It works, with two drawbacks. Yielding after every item is wasteful if items are cheap. And the continuation goes to the back of the task queue, so any other tasks already queued, including third-party ones, run before your remaining work resumes (source 3).

Note that awaiting a resolved promise on its own does not yield. Promise callbacks run as microtasks within the same task, so the browser never gets a chance to paint between them (source 1).

The better way: scheduler.yield

scheduler.yield() yields to the main thread and gives the continuation a higher priority than starting new tasks. Chrome's documentation describes it as prioritizing the continuation of a task over starting new ones, so your remaining work resumes before unrelated queued tasks, while input and rendering still get their turn (source 3).

Support at the time of writing, according to Chrome's documentation: Chromium-based browsers from version 129 (Chrome and Edge) and Firefox from version 142; Safari does not support it (source 3). A fallback is therefore needed:

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

Yield on a time budget, not every item

Yielding after every item adds overhead. A common pattern is to work until a time budget is used up, then yield:

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

A budget below 50 ms keeps each piece short of the long-task line. On slower devices the same code processes fewer items per piece, which is the point: the budget adapts to the hardware.

Do the visible update first

For interactions, order matters as much as splitting. Put the work the user needs to see first, yield, then do the rest.

button.addEventListener('click', async () => {
  showPendingState(button);   // cheap, visible feedback
  await yieldToMain();        // let the browser paint it
  const result = expensiveRecalculation();
  renderResult(result);
  await yieldToMain();
  sendAnalytics('recalculate'); // nothing visible depends on this
});

In this pattern the next frame shows the pending state quickly, so the interaction's latency is short even though the full job takes longer.

Inside frameworks

Frameworks have their own scheduling, and it is worth using before hand-rolling yields.

  • React treats updates wrapped in startTransition as non-urgent, so urgent updates such as typing can interrupt them. React 19.2's Performance Tracks show in Chrome DevTools which updates ran at "blocking" or "transition" priority (source 4).
  • Vue and Angular batch updates per tick. Long work is better moved out of watchers and computed values into explicit, chunked functions.
  • Any framework: avoid putting heavy work in render functions, and keep state updates narrow so fewer components re-render.

Where a framework update itself is the long task, for example rendering a very large list, yielding cannot split the framework's internal work. Virtualizing the list, so that only visible rows render, is the usual fix.

Other tools for the job

  • Web Workers move pure computation such as parsing, sorting or diffing large data off the main thread entirely. They cannot touch the DOM, and passing large data back and forth has its own cost.
  • scheduler.postTask() schedules tasks with priorities (user-blocking, user-visible, background) in browsers that support it (source 1).
  • requestIdleCallback runs work when the browser is idle, which suits truly optional work, but it may never run on a busy page (source 1).
  • Doing less. Often the largest gain comes from removing work: caching results, avoiding duplicate calculations, or dropping a dependency.

Yielding inside long-running loops in frameworks

A frequent case is a framework component that processes data before rendering, such as grouping thousands of records for a table. Doing that inside a render function makes it part of the framework's render task, where you cannot yield. Move the processing into an async function that runs in chunks, store the result in state when it is done, and render a lightweight placeholder meanwhile:

async function groupInChunks(records, budgetMs = 40) {
  const groups = new Map();
  let deadline = performance.now() + budgetMs;
  for (const r of records) {
    const list = groups.get(r.category) || [];
    list.push(r);
    groups.set(r.category, list);
    if (performance.now() >= deadline) {
      await yieldToMain();
      deadline = performance.now() + budgetMs;
    }
  }
  return groups;
}

Call it from an effect or event handler, not from render, and guard against stale results if the input changes before the work finishes, for example by checking a request counter before saving the result.

Testing on slow hardware

The value of yielding is easiest to see on a slow device. CPU throttling in DevTools is a good approximation, and testing on a real low-end Android phone through remote debugging is better still, because thermal limits and memory pressure appear there too. Record the interaction before and after the change and compare the longest task and the time to the first paint after input.

When not to yield

Yielding is not always right. Work that must finish before the next frame for the page to look correct, such as measuring and positioning a popover the user just opened, should stay together, otherwise the user sees an intermediate state. Very short tasks do not need splitting at all, and yielding inside them only adds overhead. And yielding does not reduce total work; if a job is simply too large, removing or caching work beats spreading it out.

Checking the result

After changes, record a trace on a throttled CPU and confirm that the interaction's work now appears as several shorter tasks with a paint between the first and the rest. In the field, watch the processing and input delay phases in your INP attribution. The long-task budget planner can help you choose how finely to split work before you start.

Sources

  1. Optimize long tasks, web.dev Primary source (Google)
  2. Interaction to Next Paint (INP), web.dev Primary source (Google)
  3. Use scheduler.yield() to break up long tasks, Chrome for Developers Primary source (Google)
  4. React 19.2, React blog Primary source (React team)

Ready when your team is

Plan your rendering review with us

Tell us about your app, its framework and where it feels slow. We reply with questions and a fixed written quote.

Start a conversation
  • Fixed written quote
  • Written findings
  • No obligation