Wrap the per-entry loop in an async function that calls await scheduler.yield() whenever a few milliseconds have passed, re-check that each target is still connected after every yield, and fall back to setTimeout(0) where scheduler.yield is unavailable.
Problem / Scenario Context
A photo-sharing feed registers each post with a shared IntersectionObserver. When posts enter, the callback swaps image sources, renders like counts from a cache, formats relative timestamps, and attaches event listeners for the post's menu. Scrolling quickly delivers twenty or thirty entries at once, and the callback takes 90–120 ms on a mid-range phone. Field data shows poor INP concentrated on the feed page: users tap "like" while scrolling and the heart takes a noticeable moment to fill.
The per-entry work is individually fine. The problem is that it all happens in one task. The Scheduling Observer Work Off the Critical Path topic describes the general approach; this page implements yielding.
Mechanics Explanation
The browser can only process an input event between tasks. While a task runs, a tap is queued; its handler runs after the task finishes, and the frame showing its result comes after that. INP records the whole wait.
scheduler.yield() returns a promise that resolves in a new task. Awaiting it ends the current task, which lets the browser run any pending input handlers and, if a frame is due, render. What distinguishes it from await new Promise(r => setTimeout(r)) is queue position: the continuation is placed in a special continuation queue that runs ahead of ordinary tasks at the same priority. After the input is handled, your loop resumes before any analytics beacon or third-party timer that queued up in the meantime.
Because each slice is its own task, a yielded loop also appears in traces as several short tasks rather than one long one, and none of them counts as a long task if each stays under 50 ms.
Comparison Table: Ways to Break Up the Loop
| Technique | Lets input run? | Keeps queue position? | Cost per yield | Support |
|---|---|---|---|---|
| No yielding | no | n/a | none | everywhere |
await Promise.resolve() |
no — microtask | n/a | tiny | everywhere |
await new Promise(r => setTimeout(r)) |
yes | no — back of queue, ≥ 4 ms clamp after nesting | small | everywhere |
await scheduler.yield() |
yes | yes | small | Chromium; others arriving |
scheduler.postTask(fn) per chunk |
yes | no | small | Chromium, Firefox |
MessageChannel post trick |
yes | no | small | everywhere |
Minimal Reproducible Example
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
hydratePost(e.target as HTMLElement); // ~4 ms each; 25 entries = ~100 ms
}
});
document.querySelectorAll('.post').forEach((p) => io.observe(p));
declare function hydratePost(el: HTMLElement): void;
In a Performance trace, flick-scroll the feed: a single long task per batch, flagged in red, with the tap's event handler waiting behind it.
Production-Safe Solution
type Yield = () => Promise<void>;
const yieldToMain: Yield = (() => {
const s = (globalThis as { scheduler?: { yield?: Yield } }).scheduler;
if (typeof s?.yield === 'function') return () => s.yield!();
// Fallback: a MessageChannel avoids setTimeout's nesting clamp.
const ch = new MessageChannel();
const queue: Array<() => void> = [];
ch.port1.onmessage = () => queue.shift()?.();
return () => new Promise<void>((r) => { queue.push(r); ch.port2.postMessage(null); });
})();
interface SlicedOptions { budgetMs?: number; signal?: AbortSignal }
export async function forEachSliced<T>(
items: readonly T[],
fn: (item: T) => void,
{ budgetMs = 8, signal }: SlicedOptions = {},
): Promise<void> {
let start = performance.now();
for (const item of items) {
if (signal?.aborted) return;
fn(item);
if (performance.now() - start >= budgetMs) {
await yieldToMain();
start = performance.now();
}
}
}
// Usage inside the observer
const controller = new AbortController();
const io = new IntersectionObserver((entries) => {
const entering = entries.filter((e) => e.isIntersecting);
// Frame-critical work first, synchronously: cheap and visible.
for (const e of entering) {
const img = e.target.querySelector<HTMLImageElement>('img[data-src]');
if (img) img.src = img.dataset.src!;
}
// Everything else in slices.
void forEachSliced(entering, (e) => {
const el = e.target as HTMLElement;
if (!el.isConnected) return; // may have been removed during a yield
hydratePost(el);
}, { signal: controller.signal });
}, { rootMargin: '300px 0px' });
// On route change / unmount:
// controller.abort(); io.disconnect();
The image swap stays synchronous because it is cheap and is the part the user sees first. The heavier hydration runs in slices, each re-checking isConnected, and the whole loop stops if the view is torn down mid-batch.
Choosing a Budget
The budget trades throughput for responsiveness. A few measurements on the target device settle it better than intuition:
- Measure per-item cost. If one
hydratePosttakes 4 ms on a mid-range phone, an 8 ms budget processes two items per slice. A 3 ms budget would still process one item per slice — the budget is a floor on responsiveness, not a guarantee, when a single item is slow. - Keep slices well under 50 ms. That is the long-task threshold; staying under it also keeps Total Blocking Time low in lab tests.
- Account for the frame. At 60 Hz there are about 16 ms per frame. An 8 ms slice leaves room for the browser's own rendering work in the same frame.
- Smaller on busy pages. If animations or video decoding already occupy the main thread, drop the budget to 4–5 ms.
If a single item regularly exceeds the budget, yielding cannot help that item; split the item's own work (render the visible part now, attach listeners later) or move computation off the main thread.
Edge Cases
Overlapping batches. A second callback can arrive while the first is still yielding through its slices. Both loops then interleave. That is usually fine, but if order matters — rendering in feed order, say — push entries into a single queue drained by one loop instead of starting a loop per callback.
Entries that exit during the wait. A post can scroll out of view between being queued and being hydrated. Hydrating it anyway is harmless for idempotent work; for work that should only happen while visible, track current visibility in a Set updated synchronously by the callback and check it inside the slice.
Unmount during a yield. Framework components can unmount while a loop is suspended. The abort signal stops the loop; isConnected catches elements removed by other means.
Testing environments. JSDOM and older test runners lack scheduler. The MessageChannel fallback works there too, but fake timers do not advance message events — use real timers for these tests, or inject the yield function.
Verification Steps
- Record a trace during a fast scroll: the batch should appear as several short tasks, none flagged as long.
- Tap during the scroll with the Interactions track open; the input delay should be a few milliseconds.
- Log
schedulersupport in your field telemetry to know how many users get the fallback. - Navigate away mid-batch and confirm no errors from hydrating disconnected elements.
- Compare field INP on the page before and after; the 75th percentile is what matters.
Common Mistakes to Avoid
- Yielding with a microtask.
await Promise.resolve()does not end the task; input still waits. - Yielding after every item regardless of cost. Hundreds of trivial items each followed by a yield add avoidable overhead; use a time budget.
- Forgetting the connectivity check. After a yield, the DOM may have changed completely.
- Deferring the visible part. Setting
srcor toggling a class is cheap; deferring it makes content late for no benefit.
FAQ
Does scheduler.yield work inside ResizeObserver callbacks?
The callback itself runs inside the rendering steps, so work after the first await runs in a later task, after paint. That is fine for non-visual work, but anything that must land in the same frame as the resize has to happen before the first yield.
Why not use a Web Worker instead?
Workers cannot touch the DOM, and most observer callback work is DOM work. Pure computation — sorting, parsing, layout maths — can move to a worker, which pairs well with yielding for the DOM part.
Is the MessageChannel fallback safe?
Yes. It schedules a task without the minimum delay that nested setTimeout calls incur. It does not get the priority boost of scheduler.yield, but it does let input run.
Does yielding affect the order entries are processed?
Not within one loop. Separate callbacks may interleave, so use one shared queue when order across batches matters.
Related
- Prioritising Observer Tasks with scheduler.postTask — explicit priorities for the deferred work
- Keeping Observer Callbacks Under the INP Budget — measuring the effect
- Optimizing IntersectionObserver for 1000 List Items — large batches