Wrap each observer callback in a helper that records a performance.measure() with a stable name and a detail payload (entry count, observer name), read those measures back with a PerformanceObserver, and summarise durations as percentiles per callback — sampled, so the instrumentation never becomes the cost.
Problem / Scenario Context
A team has fixed a slow IntersectionObserver callback on their search results page after finding it in a lab trace. Two weeks later they want to know whether the fix held in production, across real devices, and whether any other observer callbacks are slow for real users. Lab traces cannot answer either question; they need numbers from the field, labelled by callback.
User Timing is the bridge. The same marks that label callbacks in a DevTools trace can be observed by the page itself and sent as telemetry. The broader workflow is in Profiling Observer Performance in DevTools.
Mechanics Explanation
The User Timing API has two calls:
performance.mark(name, { startTime?, detail? })records a named point in time.performance.measure(name, { start, end?, duration?, detail? })records a named interval, either between marks or from explicit timestamps.
Both create entries in the performance timeline. DevTools shows measures as labelled bars in the Timings track of a trace, and a PerformanceObserver observing type measure (or mark) receives them in the page. The detail field accepts any structured-cloneable value — a place to put entry counts, observer names or route names — and survives into both DevTools (shown in the Summary pane) and the observer entries.
The overhead is small but not zero: each call allocates an entry object, and the performance entry buffer has a limit (entries beyond it are dropped from the global buffer, though observers still receive them). Sampling keeps the overhead negligible.
Comparison Table: Ways to Time a Callback
| Method | Visible in DevTools? | Readable in the field? | Structured data? | Overhead |
|---|---|---|---|---|
console.time / timeEnd |
console only | no | no | low |
performance.now() deltas |
no | yes, manual | manual | lowest |
performance.mark + measure |
yes, Timings track | yes, via PerformanceObserver | detail |
low |
| Long Animation Frames | no (in field) | yes | script attribution | none for you |
| Sampling profiler (JS Self-Profiling) | no | yes | stacks | moderate |
Minimal Reproducible Example
// Manual timing: works, but invisible in traces and ad-hoc in the field.
const io = new IntersectionObserver((entries) => {
const t0 = performance.now();
renderResults(entries);
const dt = performance.now() - t0;
if (dt > 16) console.warn('slow', dt);
});
declare function renderResults(entries: IntersectionObserverEntry[]): void;
Production-Safe Solution
// 1. A wrapper for any observer callback type.
type AnyCallback<E> = (entries: E[], observer: unknown) => void;
interface TimingOptions { sampleRate?: number }
const SAMPLED = Math.random() < 0.1; // decide once per page view
export function timedCallback<E>(name: string, cb: AnyCallback<E>, { sampleRate = 0.1 }: TimingOptions = {}): AnyCallback<E> {
const active = sampleRate >= 1 || SAMPLED;
if (!active) return cb;
return (entries, observer) => {
const start = performance.now();
try {
cb(entries, observer);
} finally {
performance.measure(`observer:${name}`, {
start,
duration: performance.now() - start,
detail: { entries: entries.length, route: location.pathname },
});
}
};
}
// 2. Use it for every observer.
const io = new IntersectionObserver(timedCallback('results', (entries) => renderResults(entries)));
const ro = new ResizeObserver(timedCallback('chart-resize', (entries) => resizeCharts(entries)));
declare function resizeCharts(entries: ResizeObserverEntry[]): void;
// 3. Collect and summarise in the page; send once.
const durations = new Map<string, number[]>();
new PerformanceObserver((list) => {
for (const m of list.getEntries()) {
if (!m.name.startsWith('observer:')) continue;
const arr = durations.get(m.name) ?? [];
if (arr.length < 500) arr.push(m.duration); // cap memory
durations.set(m.name, arr);
}
}).observe({ type: 'measure' });
function pct(xs: number[], p: number): number {
const s = [...xs].sort((a, b) => a - b);
return s[Math.min(s.length - 1, Math.floor(p * s.length))];
}
addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'hidden' || durations.size === 0) return;
const summary = [...durations].map(([name, xs]) => ({
name, n: xs.length, p50: pct(xs, 0.5), p75: pct(xs, 0.75), p95: pct(xs, 0.95),
}));
navigator.sendBeacon('/rum/observers', JSON.stringify(summary));
durations.clear();
});
Sampling is decided once per page view, so a sampled session has complete data rather than a random 10% of callbacks. Summarising in the page means one beacon per session instead of one per callback.
Choosing What to Put in detail
The detail payload is what turns a duration into a diagnosis. Useful fields for observer callbacks:
entries— batch size. Slow callbacks with huge batches point at dense thresholds or too many targets; slow callbacks with tiny batches point at expensive per-entry work.route— which page or view. Leaks and slow callbacks are often specific to one view.intersecting— how many entries were entering versus leaving; many callbacks do all their work on entry.deviceMemory/hardwareConcurrency— rough device class, to separate "slow everywhere" from "slow on low-end devices".
Keep it small and free of personal data. detail is structured-cloned for each entry, so large objects add real overhead.
Setting Alerts on Observer Callbacks
Once the summaries flow into a dashboard, turn them into regressions you hear about. A simple scheme works well: for each callback name, alert when its p75 over a day exceeds twice its trailing two-week p75, and the sample count is large enough to be meaningful. Relative thresholds catch regressions in callbacks that were always somewhat slow, while an absolute ceiling — say 50 ms at p75 — catches new callbacks that ship slow from day one.
Tie each alert to a release marker so the investigation starts from a diff rather than a hunch. The measure names double as search terms: observer:results in the alert, in the trace and in the code all refer to the same function.
Verification Steps
- Record a trace and confirm each
observer:*measure appears in the Timings track with itsdetailin the Summary pane. - Log the summary locally before sending to confirm percentiles look plausible.
- Check the beacon arrives once per session, on tab hide.
- Compare lab and field numbers for the same callback; field p75 is usually several times the lab median.
- Watch the dashboard after each release for regressions by callback name.
Common Mistakes to Avoid
- Measuring every callback in every session. Sample; the aggregate is just as informative.
- Sending a beacon per measure. It adds network and main-thread cost; summarise locally.
- Unbounded arrays. A long session can collect tens of thousands of durations; cap them.
- Using unique names per call. Keep names stable (
observer:results) and put variable data indetail.
FAQ
Do User Timing measures slow down the page?
Each call allocates a small entry. For a few callbacks per second the cost is negligible; for hundreds per second, sample or measure only callbacks that exceed a threshold.
Why not just use the Long Animation Frames API?
LoAF tells you which scripts made frames long, without any instrumentation, which is excellent for discovery. User Timing tells you the exact cost of each named callback every time it runs, including fast ones, which is better for tracking a known callback over time. They complement each other.
Can I see the detail payload in DevTools?
Yes. Click a measure in the Timings track; the Summary pane shows its detail object.
Should I clear marks and measures?
If you create many and never read them from the global buffer, clearing with performance.clearMeasures(name) keeps the buffer from filling. Observers receive entries regardless of the buffer.
How do I time ResizeObserver callbacks that loop?
Each iteration of the loop is a separate callback invocation, so each produces its own measure. Multiple measures for the same name within one frame is itself a useful signal that the loop is iterating.
Related
- Reading Observer Callbacks in the Chrome Performance Panel — where measures appear in a trace
- Keeping Observer Callbacks Under the INP Budget — budgets to compare against
- PerformanceObserver Buffered Entries Explained — how the observer receives earlier measures