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.

A 110 ms Callback, Yielded Every 8 msTwo lanes. Without yielding, the callback is one one-hundred-and-ten millisecond task and a tap at thirty milliseconds waits for all of it. With yielding, the callback runs in slices of about eight milliseconds; the tap handler runs right after the slice in progress, and the loop continues afterwards ahead of other queued tasks.Tap arrives at 30 msno yieldone 110 ms tasktapscheduler.yields1s2s3s4taps50ms20ms40ms60ms80ms100ms120ms140mstap

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

TypeScript
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

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

What Happens Around Each YieldFour boxes. A slice processes entries until the budget is spent. The loop awaits the yield, which ends the task. The browser runs any pending input handler and may render a frame. The continuation resumes ahead of other queued tasks and re-checks that the next element is still connected before continuing.Slicework until ~8 ms spentawait yieldtask ends hereBrowser turninput handlers, maybe aframeContinuationresumes first;re-check isConnected

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

Budget Versus Worst Input Delay and Total TimeBars for four slice budgets on a batch of twenty-five posts. With no yielding the worst input delay is about one hundred and ten milliseconds. A thirty-two millisecond budget gives about thirty-four. Eight milliseconds gives about twelve. Two milliseconds gives about six but adds noticeable scheduling overhead to the total time.25 posts at ~4 ms each, worst tap delay during the batchno yielding~110 ms32 ms budget~34 ms8 ms budget~12 ms2 ms budget~6 ms, +15% total time

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 scheduler support 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 src or 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.


↑ Back to Scheduling Observer Work Off the Critical Path