Breaking Long Tasks into Yielding Chunks

A 400 ms task is not slow because of what it computes; it is slow because for 400 ms the browser cannot handle a click, run an animation, or paint a frame. Slicing it into thirty-five pieces does not make it faster — it makes the page usable while it runs.

This is the main-thread technique behind Task Scheduling & Prioritization, under High-Performance Computation Patterns. It applies to work that cannot be offloaded: DOM construction, layout measurement, canvas draws on the main thread, and anything that needs a browser API workers do not have.


The Chunker

// chunk.ts — a reusable slicing helper with a time budget
export interface ChunkOptions {
  budgetMs?: number;        // work per slice
  signal?: AbortSignal;
  onProgress?: (done: number, total: number) => void;
}

export async function forEachChunked<T>(
  items: readonly T[],
  each: (item: T, index: number) => void,
  { budgetMs = 8, signal, onProgress }: ChunkOptions = {},
): Promise<void> {
  let start = performance.now();

  for (let i = 0; i < items.length; i++) {
    each(items[i], i);

    // Time-based, not count-based: per-item cost is never uniform.
    if (performance.now() - start >= budgetMs) {
      onProgress?.(i + 1, items.length);
      await yieldToMain();
      signal?.throwIfAborted();          // cancel between slices, never mid-slice
      start = performance.now();
    }
  }
  onProgress?.(items.length, items.length);
}

export function yieldToMain(): Promise<void> {
  const s = (globalThis as { scheduler?: { yield?: () => Promise<void> } }).scheduler;
  if (s?.yield) return s.yield();                       // best: keeps queue position
  return new Promise((resolve) => {                     // portable macrotask
    const channel = new MessageChannel();
    channel.port1.onmessage = () => resolve();
    channel.port2.postMessage(undefined);
  });
}

Three decisions are baked in. The budget is measured in time, because a loop over rows of wildly different sizes cannot be sliced by count. Cancellation is checked after the yield, so a slice is never abandoned half-finished. And the yield is a macrotask, because a microtask does not return control to the browser at all.

One long task versus the same work in yielding slices Two main-thread timelines for identical work. The first is a single 400 millisecond task: a click that arrives 80 milliseconds in is not handled until the task ends, so the interaction latency is 320 milliseconds and no frames are painted throughout. The second is the same work in fifty 8 millisecond slices with a yield between each: frames paint between slices, and the click that arrives at the same moment is handled at the next yield, giving an interaction latency of about 9 milliseconds. Total work time grows by roughly 4 percent. Same work, same thread, different granularity one task 400 ms — no input handled, no frame painted click interaction latency 320 ms 50 slices click interaction latency ≈ 9 ms — handled in the mustard gap frames paint between slices; total work grows about 4% from yield overhead Interaction to Next Paint measures the tall bar, not the sum of the small ones.
The metric being optimised is the height of the tallest block, not the total width — which is why a slightly slower job can score dramatically better.

Three Yield Primitives, Ranked

Primitive Returns control to Queue position after Availability
scheduler.yield() the event loop, honouring priority ahead of newly posted same-priority tasks Chromium 129+
MessageChannel ping the event loop behind everything already queued everywhere
setTimeout(fn, 0) the event loop, after clamping behind everything, plus 0–4 ms everywhere
await Promise.resolve() nothing — microtask only n/a everywhere

The last row is the trap. Microtasks run to completion before the event loop regains control, so a loop that “yields” this way still blocks input for its whole duration while looking correct in review.

The difference between the first two rows shows up on pages with competing scripts. scheduler.yield() resumes your continuation ahead of tasks posted after it, so a sliced loop finishes in close to its own time. With a MessageChannel or setTimeout, every yield puts you at the back of the queue, and on a page with a chatty analytics script a 400 ms job can stretch to well over a second in wall clock — same slices, much longer elapsed.

setTimeout(0) additionally suffers the nested-timer clamp: after five nested timers browsers enforce a 4 ms minimum, which on a 50-slice loop adds around 200 ms of pure waiting. Prefer the MessageChannel fallback for exactly this reason.


Yield Only When Something Is Waiting

Yielding on a schedule is slightly wasteful: most yields return immediately because nothing was pending. Chromium exposes navigator.scheduling.isInputPending(), which answers “is there an input event waiting to be dispatched?” and lets a loop run uninterrupted until one appears:

const inputPending = (): boolean =>
  (navigator as never as { scheduling?: { isInputPending?: () => boolean } })
    .scheduling?.isInputPending?.() ?? false;

for (let i = 0; i < items.length; i++) {
  each(items[i], i);
  const elapsed = performance.now() - start;
  // Yield early if input is waiting; otherwise run up to a hard ceiling.
  if ((elapsed >= 4 && inputPending()) || elapsed >= 16) {
    await yieldToMain();
    start = performance.now();
  }
}

The hard ceiling is not optional. isInputPending reports input, not rendering — a loop that only yields for input starves animation frames and never lets the page paint. The pairing above (“yield early for input, always yield by 16 ms”) keeps both properties, and degrades to plain time-slicing where the API is missing.

Overhead and responsiveness across slice budgets A table-shaped comparison across four slice budgets for the same 400 millisecond job. A 2 millisecond budget gives 200 slices, about 18 percent overhead and excellent responsiveness. A 5 millisecond budget gives 80 slices and about 7 percent overhead. An 8 millisecond budget gives 50 slices and about 4 percent overhead, marked as the recommended default. A 16 millisecond budget gives 25 slices and about 2 percent overhead but each slice can consume a whole frame on its own, so responsiveness is only fair. Choosing the slice budget for a 400 ms job budget slices yield overhead responsiveness 2 ms 200 ≈ 18% excellent, and wasteful 5 ms 80 ≈ 7% excellent 8 ms 50 ≈ 4% good — the default 16 ms 25 ≈ 2% fair — a slice can eat a frame Overhead is dominated by how often you yield, not by how much work each slice does.
Below 5 ms the curve turns sharply: you pay a lot of overhead for responsiveness the user cannot perceive.

Keeping the DOM Consistent Across a Yield

A yield is a point at which the browser may paint. Anything half-finished at that moment is visible for at least one frame, which produces flashes of empty containers, rows without their siblings, or a list that reflows repeatedly as it grows.

Three habits prevent it:

Build off-document, attach in one step. Append into a DocumentFragment and insert it at the end of a slice, so the DOM only ever transitions between complete states.

Reserve the space before filling it. Set the container’s height (or render skeleton rows) before the first slice, so growth does not shift the content around the user’s cursor. Layout shift caused by progressive rendering is measured by Cumulative Layout Shift and is a genuine regression even though nothing is slower.

Do not yield inside a read-then-write pair. If a slice measures an element and then mutates based on the measurement, a yield between the two lets other code invalidate the measurement — the classic layout-thrash bug, with a wider window than usual.

// Batch reads and writes; yield only at the boundary between whole units.
await forEachChunked(rows, (row, i) => {
  const el = buildRow(row);            // no layout reads here
  fragment.append(el);
  if (i % 100 === 99) { container.append(fragment); }   // whole-batch attach
}, { budgetMs: 8 });

Generators for Composable Chunking

When the work is not a flat array — a tree walk, a state machine, a recursive layout — a generator expresses the yield points naturally, and the driver stays generic:

function* walkTree(node: Node): Generator<Node> {
  yield node;
  for (const child of node.children) yield* walkTree(child);
}

export async function drive<T>(gen: Generator<T>, step: (v: T) => void, budgetMs = 8) {
  let start = performance.now();
  for (const value of gen) {
    step(value);
    if (performance.now() - start >= budgetMs) { await yieldToMain(); start = performance.now(); }
  }
}

The generator holds the traversal state, so pausing costs nothing and resuming needs no index bookkeeping. This is the shape to reach for whenever “where was I?” is more complicated than an integer — and it composes: several generators can be interleaved by one driver that round-robins between them, which is a fair-share scheduler in ten lines.


Yielding Inside a Worker

Slicing is usually framed as a main-thread technique, but a worker needs it for a different reason: its message queue only drains between tasks. A worker running a 900 ms uninterrupted loop cannot see a cancellation, cannot answer a higher-priority request, and cannot report progress — its inbox is simply not serviced.

// worker.ts — the same chunker, yielding for message delivery rather than for paint
import { forEachChunked, yieldToMain } from './chunk';

let cancelled = false;
self.addEventListener('message', (e) => { if (e.data?.kind === 'CANCEL') cancelled = true; });

export async function aggregate(rows: Float64Array): Promise<number> {
  let sum = 0;
  for (let i = 0; i < rows.length; i++) {
    sum += rows[i];
    if ((i & 0xffff) === 0) {
      await yieldToMain();               // a macrotask — messages are delivered here
      if (cancelled) throw new DOMException('aborted', 'AbortError');
    }
  }
  return sum;
}

The budget can be far more generous than on the main thread: nothing is painting, so 30–50 ms slices are fine and the overhead is proportionally smaller. What matters is only that the gap between yields is shorter than the latency you are willing to accept for a cancel or a priority change — typically a frame or two, so that a user who clicks “stop” sees it take effect immediately.


Work that must yield, work that should move, and work to leave alone Three categories of long task. DOM construction, layout measurement and main-thread canvas work cannot move to a worker, so slicing with a time budget is the only option. Parsing, aggregation, image processing and compression should move to a worker instead, where they need no slicing to keep the page responsive. Anything under about fifty milliseconds should be left alone, because the slicing overhead and complexity buy nothing measurable. Slice it, move it, or leave it DOM building, layout reads, main-thread canvas cannot move — slice with a time budget and yield between units parsing, aggregation, image work, compression move it to a worker; no slicing needed once it is off-thread anything under roughly 50 ms leave it alone — the overhead and complexity buy nothing A mixed task usually splits: compute in the worker, apply to the DOM in yielding slices.
The last row matters as much as the first: slicing a task that was never long is pure cost.

Gotchas

Yielding inside a for...of over a live NodeList. The collection can change while you are paused, so indices shift and nodes are visited twice or missed. Snapshot with Array.from before slicing.

Slicing something that was already fast. A 30 ms task does not need 4 slices; it needs to be left alone. Slicing has a floor of usefulness, and below ~50 ms the overhead and the added complexity buy nothing measurable.

Progress callbacks that touch the DOM every slice. Updating a progress element 50 times is fine; updating a text node with a percentage that also triggers a layout read is a second long task in disguise.

Cancellation checked mid-slice. Throwing halfway through building a row leaves a partial DOM. Check after the yield, at a boundary where state is consistent.

Assuming the budget holds under CPU throttling. A slice calibrated to 8 ms on a development machine can take 40 ms on a low-end phone if it is count-based. Time-based budgets adapt automatically, which is the main argument for them.


Performance Note

Rendering 8,000 rows on a 2023 laptop with 4× CPU throttling: one task took 412 ms and produced an Interaction to Next Paint of 438 ms for a click that arrived during it. The same rows with an 8 ms budget took 429 ms total — 4% slower — with a longest task of 11 ms and an INP of 24 ms. On the same hardware, moving row construction to a worker and keeping only the insertion on the main thread cut total time to 190 ms, because the string building and formatting left the main thread entirely.

Frequently Asked Questions

How long should each slice be?
Aim for 5–12 ms of work per slice. Below about 3 ms the yield overhead becomes a noticeable fraction of the job; above about 16 ms a slice can miss a frame on its own, which defeats the purpose. Measure the slice with performance.now() rather than counting iterations, because per-item cost varies with the data.
Is yielding better than moving the work to a worker?
They solve different problems. If the work touches the DOM it cannot move, so yielding is the only option. If it is pure computation, a worker removes it from the main thread entirely and is strictly better — yielding still spends every millisecond of main-thread time, it merely spreads it out. The common case is a mix: compute in the worker, render in yielding slices.

See also