An observer callback is just a task on the main thread, and every millisecond it spends is a millisecond an input event waits. The fix is rarely to make the callback faster in total — it is to do the urgent part now and schedule everything else behind the user's next interaction.

Concept Framing

The other topics in Performance Optimization & Memory Management reduce how much work observers do: throttling and debouncing cuts the number of callbacks, DOM query minimization cuts the layout cost inside them, and pooling cuts the per-instance overhead. This topic is about when the remaining work runs.

A realistic intersection callback for a product grid does several things when a batch of cards enters the viewport: it swaps image sources, starts prefetching detail pages, records impressions, hydrates interactive widgets and perhaps renders recommendations. Only the first is visible within the next frame. The rest can happen a few hundred milliseconds later without anyone noticing — unless they all run in the same task, in which case a tap on "Add to cart" during that task waits for all of them.

Interaction to Next Paint (INP) measures exactly that wait: the time from an input to the next frame that reflects it. A 150 ms observer callback that happens to overlap a tap adds up to 150 ms to that interaction. The browser's scheduling APIs exist to break such tasks into pieces with room for input in between.

One Long Callback Versus a Split CallbackTwo lanes over two hundred milliseconds. In the first, a single observer callback runs for one hundred and forty milliseconds and a tap arriving at forty milliseconds waits until the callback finishes. In the second, the callback does the urgent image swap, yields, and the tap handler runs immediately; the remaining work continues afterwards in smaller tasks.A tap arrives 40 ms into the callbackone long taskIO callback: everythingtap handleryieldingswap srctap handlerimpressionsprefetchhydrate0ms25ms50ms75ms100ms125ms150ms175ms200mstap

Spec / Signature Reference Table

The scheduling primitives available today differ in priority, cancellation and whether they keep your place in line.

API Runs Priority Cancellable Continuation keeps priority?
await scheduler.yield() ASAP after pending input inherits caller's via signal yes — resumes ahead of new tasks
scheduler.postTask(fn, { priority }) new task user-blocking, user-visible, background TaskController n/a
requestIdleCallback(fn, { timeout }) when idle, or at timeout lowest cancelIdleCallback no
setTimeout(fn, 0) new task, ≥ 0–4 ms later normal clearTimeout no — goes to the back of the queue
requestAnimationFrame(fn) before next render frame-aligned cancelAnimationFrame n/a
queueMicrotask(fn) end of current task same task no does not yield at all

scheduler.yield() is the key newer primitive. Unlike setTimeout(0), the continuation it returns is scheduled ahead of other queued tasks of the same priority, so yielding does not mean losing your place behind every third-party script on the page. Support is in Chromium-based browsers and arriving elsewhere; a fallback to setTimeout or postTask is straightforward.

Step-by-Step Implementation

Step 1: Classify the work inside the callback

TypeScript
type Urgency = 'frame' | 'soon' | 'idle';

interface WorkItem { urgency: Urgency; run: () => void }

frame work must affect the next paint (setting src, adding a class). soon work should happen within a few hundred milliseconds but may yield to input (hydrating a widget). idle work can wait for a quiet moment (analytics, prefetch).

Step 2: Write a portable yield

TypeScript
export function yieldToMain(): Promise<void> {
  const s = (globalThis as { scheduler?: { yield?: () => Promise<void> } }).scheduler;
  if (s?.yield) return s.yield();
  return new Promise((resolve) => setTimeout(resolve, 0));
}

Step 3: Process entries with a time budget

TypeScript
const BUDGET_MS = 8;

export async function processEntries(
  entries: IntersectionObserverEntry[],
  handle: (e: IntersectionObserverEntry) => void,
): Promise<void> {
  let sliceStart = performance.now();
  for (const entry of entries) {
    handle(entry);
    if (performance.now() - sliceStart > BUDGET_MS) {
      await yieldToMain();                 // let input and rendering in
      sliceStart = performance.now();
    }
  }
}

A budget of around 8 ms per slice leaves room for the browser to run an input handler and still produce a frame at 60 Hz.

Step 4: Route non-frame work to the right scheduler

TypeScript
export function schedule(item: WorkItem): void {
  const s = (globalThis as { scheduler?: { postTask?: Function } }).scheduler;
  if (item.urgency === 'frame') return item.run();
  if (item.urgency === 'soon') {
    s?.postTask ? s.postTask(item.run, { priority: 'user-visible' }) : setTimeout(item.run, 0);
    return;
  }
  'requestIdleCallback' in window
    ? requestIdleCallback(() => item.run(), { timeout: 2000 })
    : setTimeout(item.run, 200);
}

The timeout on requestIdleCallback matters: on a page that is never idle — an animation loop, a busy dashboard — idle callbacks would otherwise never run and analytics would be lost.

Scheduling Observer Work, Step by StepFour steps. Classify each piece of work as frame, soon or idle. Provide a portable yield that uses scheduler.yield where available. Process entries in slices of about eight milliseconds, yielding between slices. Route soon work to postTask at user-visible priority and idle work to requestIdleCallback with a timeout.1Classifyframe: must paint next; soon: within ~200 ms; idle: whenever quiet.2Portable yieldscheduler.yield() when present, setTimeout(0) otherwise.3Budgeted slicesProcess entries until ~8 ms have passed, then yield.4Route the restpostTask user-visible for soon work; idle callback with a timeout for the rest.

Threshold / Configuration Variants

The right budget and routing depend on what the page is doing.

Page type Slice budget soon priority idle timeout Notes
Content site, light JS 10–16 ms user-visible 2 s little contention; bigger slices fine
E-commerce grid 6–8 ms user-visible 1–2 s taps during scroll are common
Dashboard with live charts 4–5 ms user-visible 500 ms frames are busy; idle is rare
Infinite feed with video 5 ms background for prefetch 1 s decoding competes for main thread
Background tab n/a n/a n/a rendering observers do not fire

Worst Interaction Delay by Scheduling StrategyA bar chart of the worst input delay observed while a batch of forty product cards entered the viewport. Doing everything in the callback produced about one hundred and forty milliseconds of delay. Deferring with setTimeout produced about sixty, because continuations queued behind other tasks. Yielding with scheduler.yield in eight millisecond slices produced about twelve. Adding idle routing for analytics produced about ten.40 cards entering at once, mid-range phone, tap during the batchall work in the callback~140 mssetTimeout deferral~60 msscheduler.yield slices~12 msyield + idle routing~10 ms

Edge Cases & Gotchas

Yielding reorders side effects. After await yieldToMain(), anything may have happened: the element may have been removed, the component unmounted, the route changed. Re-check entry.target.isConnected (or an abort signal) after each yield before touching it.

Entries are stale after a long wait. An entry describes the intersection at entry.time. If soon work runs 300 ms later, the element may have scrolled away again. For work that should only happen while visible — starting a video, say — check current state rather than trusting the old entry.

Idle callbacks can starve. A page with a continuous requestAnimationFrame loop or a constantly busy thread may never be idle. Always pass timeout, and flush pending analytics on visibilitychange to hidden.

Microtasks do not yield. await Promise.resolve() or queueMicrotask continues in the same task; input cannot run in between. Only a new task (or scheduler.yield) gives the browser a turn.

Frameworks have their own schedulers. React's concurrent rendering already yields between units of render work, and calling startTransition from an observer callback marks the state update as interruptible. Vue and Angular do not yield during a render, so large observer-triggered updates there benefit more from manual slicing.

A Worked Example: Product Grid Callback

To make the classification concrete, here is a single callback from an e-commerce category page before and after scheduling. The page shows a grid of product cards; when cards enter the viewport, it loads their images, records impressions for merchandising analytics, prefetches the product detail data for cards that stay visible, and hydrates the "quick add" button.

Before, all four jobs ran in the callback for every entering card. With twenty cards entering on a fast scroll, the callback took about 90 ms on a mid-range phone, and taps on "quick add" buttons that were already visible regularly waited behind it.

After, the callback does only the image swap synchronously. Hydration of each card's button is posted at user-visible priority, in entry order, so the cards the user is looking at become interactive within a frame or two. Impressions are appended to a batch that an idle callback sends as one beacon. Prefetches are posted at background priority with a dwell check, and aborted if the card leaves before the task runs.

The measured result on the same device: the callback itself dropped to under 2 ms, the worst tap delay during a scroll dropped from about 90 ms to about 10 ms, and the total work done was unchanged — it simply stopped competing with the user. That last point matters when arguing for the change: scheduling does not remove work, it moves it out of the user's way, so there is no functional risk of dropping features.

The Product Grid Callback Before and AfterTwo columns. Before, one callback swaps images, hydrates buttons, records impressions and prefetches data for every entering card, taking about ninety milliseconds. After, the callback only swaps images; hydration runs at user-visible priority, impressions go to an idle batch, and prefetches run at background priority and are aborted if the card leaves.Before — one 90 ms taskSwap image sourcesHydrate every quick-add buttonSend an impression per cardPrefetch detail data per cardAfter — 2 ms callback, rest scheduledSwap image sources, synchronouslyHydrate buttons at user-visible priorityBatch impressions into one idle beaconPrefetch at background priority, abort on exit

Framework Integration Patterns

React's transition API maps cleanly onto the soon category:

TSX
import { startTransition, useCallback, useState } from 'react';

export function useVisibleIds() {
  const [ids, setIds] = useState<Set<string>>(new Set());
  const onEntries = useCallback((entries: IntersectionObserverEntry[]) => {
    // Frame work: imperative, immediate.
    for (const e of entries) if (e.isIntersecting) (e.target as HTMLImageElement).src ||= (e.target as HTMLElement).dataset.src ?? '';
    // Soon work: re-rendering lists of visible items can be interrupted by input.
    startTransition(() => {
      setIds((prev) => {
        const next = new Set(prev);
        for (const e of entries) {
          const id = (e.target as HTMLElement).dataset.id!;
          e.isIntersecting ? next.add(id) : next.delete(id);
        }
        return next;
      });
    });
  }, []);
  return { ids, onEntries };
}

In Vue, wrap non-urgent reactive updates in a postTask or idle callback; in Angular, run the observer outside the zone and re-enter only for the frame work, as described in running observer callbacks outside NgZone.

Debugging Checklist

The field attribution side is covered in observing long animation frames (LoAF).

FAQ

Is scheduler.yield better than setTimeout(0)?

Yes, where available. Both give the browser a chance to run input, but setTimeout puts your continuation at the back of the task queue, behind any other queued tasks, while scheduler.yield resumes ahead of them. That keeps your work from being starved by unrelated scripts.

What budget should each slice have?

Around 5 to 10 ms is a good default. Smaller slices add scheduling overhead; larger ones delay input. On busy pages with animations, lean smaller.

Should image swaps be deferred too?

No. Setting src is cheap and is the part the user sees; deferring it only makes images late. Keep it in the callback and defer everything around it.

Can I use requestIdleCallback for all non-urgent work?

Only for truly unimportant work. Idle callbacks run late and in unpredictable order, and on a busy page only at their timeout. Hydration and anything the user will look at soon belongs at user-visible priority instead.

Does yielding make the total work slower?

Slightly, because of scheduling overhead and because other tasks run in between. That is the point: the total takes a little longer, but no single piece blocks input, so the page feels faster.

How do I decide whether a piece of work is frame, soon or idle?

Ask what the user would notice if it were late. If they would see a missing image or a wrong state in the very next frame, it is frame work. If they would notice within a second — a widget that does not respond, content that is blank as it scrolls in — it is soon work. If they would never notice at all, such as an analytics beacon or a prefetch, it is idle work.

Does scheduling help ResizeObserver callbacks the same way?

Partly. A ResizeObserver callback runs inside the rendering steps, so anything you do there delays the current frame's paint. Keep the measure-and-adjust part in the callback, because it must land in the same frame, and post everything else — re-rendering data, recomputing aggregates — as a separate user-visible task.

What about MutationObserver callbacks?

They run at the microtask checkpoint of the task that changed the DOM, so their cost is added to that task — often a click handler or a framework render. Scheduling the non-urgent parts of a MutationObserver callback as separate tasks shortens that original task, which directly reduces processing time for the interaction that caused the mutation.

Is there a risk of work never running if I keep deferring it?

Yes, on pages that are always busy. Background and idle work can wait a long time behind higher-priority tasks. Give idle callbacks a timeout, flush anything that must not be lost when the page is hidden, and monitor in the field how long deferred work actually waits.

Should third-party scripts' observers be scheduled too?

You usually cannot change them, but you can load them later or at lower priority, and you can measure them. Long Animation Frame attribution names the script URL responsible for long frames, which is often the evidence needed to move a vendor tag behind user interaction or to ask the vendor for a lighter integration.


↑ Back to Performance Optimization & Memory Management for Observer APIs