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.

One Measure, Two ConsumersFour boxes. The wrapped observer callback records a performance measure with a name and a detail payload. In the lab, DevTools shows it as a labelled bar in the Timings track. In production, a PerformanceObserver receives the same entry. A summariser aggregates durations into percentiles per callback name and sends them once when the page is hidden.Wrapped callbackmeasure(name, detail)DevToolsTimings track barPage observersame entry, viaPerformanceObserverSummariserp50 / p75 / p95 pername

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

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

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

Field p75 Callback Duration, Before and After a FixA line chart over fourteen days of the seventy-fifth percentile duration of the results observer callback in the field. It sits around thirty-eight milliseconds for the first week, drops to about six milliseconds when the fix ships on day seven, and stays there.011.2522.533.754502.85.68.411.214daysp75 duration of observer:results (ms)before fixafter fix

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.

What the detail Fields RevealA grid mapping combinations of detail fields to diagnoses. A slow callback with a large batch size points at dense thresholds or too many targets. A slow callback with a small batch points at expensive per-entry work. Slowness concentrated on one route points at a view-specific problem. Slowness concentrated on low-memory devices points at work that should be reduced or deferred on constrained hardware.PatternLikely causeSlow + large batchesentries over 50dense thresholds, too many targetsSlow + small batchesentries under 5expensive per-entry workSlow on one routerouteview-specific callbackSlow on low-enddeviceMemory 2 or lessreduce or defer on weak devices

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 its detail in 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 in detail.

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.


↑ Back to Profiling Observer Performance in DevTools