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.
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.
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.
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.