Keep each observer callback well under 50 ms — ideally under 10 ms — by measuring its duration in the field, attributing slow interactions to overlapping callbacks with the Long Animation Frames API, and moving anything over budget into yielded or deferred tasks.

Problem / Scenario Context

A travel site's search results page has an INP at the 75th percentile of 380 ms — "poor" by Core Web Vitals thresholds, where 200 ms is the upper bound of "good". None of the site's own click handlers take more than 20 ms. The team is puzzled until someone records a trace on a phone: taps on the "Select" button during scrolling land while a 250 ms IntersectionObserver callback is rendering price calendars for results entering the viewport. The tap's handler is fast; it just has to wait.

This is the most common way observers hurt INP: not by being slow at handling input, but by being busy when input arrives. The Scheduling Observer Work Off the Critical Path topic covers the techniques; this page is about measuring and budgeting.

Mechanics Explanation

INP is made of three phases for each interaction: input delay (waiting for the main thread), processing time (your event handlers) and presentation delay (rendering the next frame). Observer callbacks contribute to the first and last:

  • Input delay. If an observer callback is running when the tap arrives, the handler waits for it to finish. The longer the callback, the larger the expected delay for a randomly timed tap — on average half its duration, at worst all of it.
  • Presentation delay. A ResizeObserver callback runs inside the rendering steps of the frame that would show the interaction's result. Expensive resize work — re-laying out a chart, say — delays that frame.

Because interactions are random with respect to observer activity, the probability of overlap matters as much as duration. A 100 ms callback that runs once at load hurts only early interactions; a 30 ms callback that runs on every scroll batch overlaps interactions constantly during scrolling.

Expected Input Delay Versus Callback DurationA line chart of expected input delay for a tap that lands during an observer callback, against the callback's duration. The average delay is half the duration and the worst case equals the duration. A dashed-style reference at two hundred milliseconds marks the poor INP boundary, which a single callback of that length can reach on its own.062.5125187.5250050100150200250callback duration in msinput delay in ms for an overlapping tapworst caseaverageINP poor boundary

Comparison Table: Budgets by Callback Type

Callback Runs Suggested budget Why
IntersectionObserver, lazy media per scroll batch < 5 ms frequent; only swaps attributes
IntersectionObserver, rendering content per scroll batch < 10 ms, yield beyond frequent and heavy
ResizeObserver, layout adjustments per resize frame < 4 ms inside rendering steps; delays paint
MutationObserver per DOM batch < 5 ms microtask; blocks the task that mutated
PerformanceObserver when idle < 10 ms rarely overlaps input

Minimal Reproducible Example

Measure every callback's duration and note whether it overlapped an interaction:

TypeScript
interface CallbackStat { name: string; duration: number; start: number }
const stats: CallbackStat[] = [];

export function timed<A extends unknown[]>(name: string, fn: (...a: A) => void) {
  return (...args: A): void => {
    const start = performance.now();
    try { fn(...args); } finally {
      const duration = performance.now() - start;
      stats.push({ name, duration, start });
      if (duration > 10) performance.measure(`observer:${name}`, { start, duration });
    }
  };
}

const io = new IntersectionObserver(timed('results-render', (entries) => { /* … */ }));

The performance.measure entries show up in the Performance panel's Timings track and can be read by a PerformanceObserver in the field.

Production-Safe Solution

Attribute poor interactions to observer callbacks in the field using Long Animation Frame (LoAF) script attribution, then enforce budgets in development.

TypeScript
interface ScriptTiming { invoker: string; duration: number; sourceURL: string; sourceFunctionName: string }
interface LoAFEntry extends PerformanceEntry { scripts: ScriptTiming[]; blockingDuration: number }

// 1. Field attribution: which scripts made frames long, and were they observer callbacks?
const loafObserver = new PerformanceObserver((list) => {
  for (const frame of list.getEntries() as LoAFEntry[]) {
    for (const s of frame.scripts) {
      // Observer callbacks appear with invokers like "IntersectionObserver.callback"
      // or "ResizeObserver.callback"; the exact string varies by browser version.
      if (/Observer/.test(s.invoker) && s.duration > 30) {
        report({ invoker: s.invoker, fn: s.sourceFunctionName, url: s.sourceURL,
                 duration: Math.round(s.duration), blocking: Math.round(frame.blockingDuration) });
      }
    }
  }
});
loafObserver.observe({ type: 'long-animation-frame', buffered: true });

// 2. Correlate with interactions: an INP entry whose start falls inside a long frame.
new PerformanceObserver((list) => {
  for (const e of list.getEntries() as PerformanceEventTiming[]) {
    if (e.interactionId && e.duration > 200) report({ interaction: e.name, inputDelay: e.processingStart - e.startTime });
  }
}).observe({ type: 'event', durationThreshold: 104, buffered: true });

declare function report(data: Record<string, unknown>): void;
TypeScript
// 3. Development guard: warn loudly when a callback exceeds its budget.
export function budgeted<A extends unknown[]>(name: string, budgetMs: number, fn: (...a: A) => void) {
  if (!import.meta.env?.DEV) return fn;
  return (...args: A): void => {
    const t = performance.now();
    fn(...args);
    const d = performance.now() - t;
    if (d > budgetMs) console.warn(`[observer budget] ${name} took ${d.toFixed(1)} ms (budget ${budgetMs} ms)`);
  };
}

With attribution in place, the travel site found that one function, renderPriceCalendar, accounted for most long frames overlapping interactions. Moving it into prioritised background tasks and yielding between results brought p75 INP to 170 ms.

From Poor INP to an Observer BudgetFour steps. Measure field INP and its input-delay phase. Use Long Animation Frame script attribution to find which scripts made the overlapping frames long. Filter for observer callback invokers and rank functions by total blocking. Set per-callback budgets and enforce them in development, fixing offenders with yielding or deferral.1MeasureField INP, broken into input delay, processing and presentation.2AttributeLoAF scripts show which callbacks ran in long frames.3RankGroup by function; sort by total blocking duration.4Budget and fixDev-time warnings; yield or defer the offenders.

Reading an Interaction Trace

When a lab trace is available, the pattern is recognisable at a glance. In the Performance panel's Interactions track, a slow interaction shows a long whisker before the handler starts — that is input delay. Directly above it on the Main track, a task will be running that started before the interaction; expand it, and the bottom-up view shows its entry point. For observer callbacks, Chrome labels the task with the callback's function name under a frame such as "Fire IntersectionObserver callbacks" or "Deliver ResizeObserver notifications".

Two variations are worth recognising:

  • Presentation delay from ResizeObserver. The handler runs promptly, but the frame after it is late. The rendering-steps portion of that frame contains the resize callback — the callback ran in the same frame as the interaction's visual update and delayed it.
  • Cascading layouts. The observer callback itself is short, but it writes styles that force a long Recalculate Style or Layout in the next frame. Budgeting the callback's JS time misses this; the forced reflow guide covers it.

Anatomy of a Slow Interaction Caused by an ObserverThree stacked layers for one interaction. Input delay: an observer callback that started before the tap is still running, so the handler waits. Processing time: the click handler itself runs quickly. Presentation delay: a ResizeObserver callback in the rendering steps of the next frame delays the paint that shows the result.Input delayAn IntersectionObserver callback started before the tap and is still running.ProcessingThe click handler itself takes a few milliseconds.Presentation delayA ResizeObserver callback in the next frame's rendering steps delays the paint.

Verification Steps

  • Check field INP by page type and confirm the pages with heavy observers are the ones with poor scores.
  • Collect LoAF attribution for a few days and rank observer callbacks by blocking time.
  • Reproduce in the lab with CPU throttling, tapping during scroll, and confirm the trace shape described above.
  • After fixing, confirm long frames attributed to observer invokers drop in the field data.
  • Keep the development budget warnings on so regressions surface in code review.

Common Mistakes to Avoid

  • Only optimising event handlers. Observer callbacks cause input delay even though they never handle input.
  • Measuring on a development laptop. A 10 ms callback there can be 60 ms on the phones that dominate your p75.
  • Ignoring ResizeObserver in presentation delay. It runs in the very frame that should show the interaction.
  • Assuming frequency does not matter. A moderately slow callback that runs on every scroll batch overlaps more interactions than a very slow one that runs once.

FAQ

Do observer callbacks count as interactions?

No. INP only measures clicks, taps and key presses. Observer callbacks affect INP indirectly, by delaying the handling or rendering of those interactions.

Is the Long Animation Frames API available everywhere?

It is available in Chromium-based browsers. Since INP field data from the Chrome UX Report also comes from Chromium, attribution there covers the population whose INP you are being measured on.

What budget should an observer callback have?

Under 10 ms for callbacks that run during scrolling is a practical target, and under 4 ms for ResizeObserver callbacks, which run inside the rendering steps. Anything above that should yield or defer.

Why does my trace show the callback under "Run Microtasks"?

That is MutationObserver, which delivers at the microtask checkpoint of the task that mutated the DOM. Its cost is added to that task — often an event handler — and so appears as processing time rather than input delay.


↑ Back to Scheduling Observer Work Off the Critical Path