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
ResizeObservercallback 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.
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:
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.
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;
// 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.
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.
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
ResizeObserverin 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.
Related
- Measuring INP with PerformanceObserver Event Timing — collecting interaction data
- Observing Long Animation Frames (LoAF) — script attribution in detail
- Correlating Long Tasks with Observer Callbacks — the long-task approach