Guide

Performance Observer API explained

Harbor Commerce's checkout flow showed a 40 ms regression in lab Lighthouse INP after upgrading its analytics chart library. CrUX field data would not reflect the change for weeks. Engineers registered a Performance Observer on longtask and event entry types with buffered: true before the deploy finished. The first production session flagged a 182 ms main-thread block during payment confirmation — attributed to a synchronous CSV parse inside the chart tooltip handler. Moving parsing to a Web Worker restored INP p75 to 168 ms within one release cycle.

The browser's Performance Timeline records paint timings, resource loads, layout shifts, interaction latency, and custom marks. The Performance Observer API lets JavaScript subscribe to those entries asynchronously instead of polling performance.getEntries() on a timer. It is the foundation of real-user monitoring (RUM) for Core Web Vitals and the hook behind libraries like web-vitals. This guide explains observer lifecycle, entry types, buffered registration, attribution fields, the Harbor Commerce dashboard refactor, a technique decision table versus Navigation Timing and vendor RUM, common pitfalls, and a production checklist alongside our resource hints guide and Intersection Observer guide.

Why polling the timeline fails

Early performance scripts called performance.getEntriesByType('resource') on window.load and shipped a batch to analytics. That pattern misses entries that arrive after load — late images, client-side navigations in SPAs, and especially interaction events that define INP. It also races: LCP can fire before your analytics bundle executes unless you register early enough.

PerformanceObserver pushes entries to a callback as the browser records them. Combined with buffered: true, an observer registered after page start still receives entries the browser buffered since navigation began — critical for async analytics tags that load late without losing the first LCP candidate.

Observer lifecycle

  • Constructnew PerformanceObserver(callback) receives a PerformanceObserverEntryList.
  • Observeobserver.observe({ type, buffered }) or legacy entryTypes: ['paint', 'navigation'] (mutually exclusive with type in modern browsers).
  • Callback — Process list.getEntries(); entries are immutable snapshots.
  • Disconnectobserver.disconnect() when tearing down SPA routes or single-page experiments to avoid leaks.

Entry types that matter for RUM

Not every entry type is available in every browser; feature-detect with PerformanceObserver.supportedEntryTypes before subscribing.

Core Web Vitals

  • largest-contentful-paint — Emits LCP candidates; the last entry before user input or navigation is the metric value. Includes renderTime, loadTime, size, and element attribution when available.
  • layout-shift — Each unexpected shift with value (fraction of viewport) and hadRecentInput. Sum shifts without recent input for CLS; session windows apply in the full metric definition.
  • event — Interaction entries for INP: duration, interactionId, and processingStart/processingEnd break down input delay, processing, and presentation delay.

Diagnostics

  • longtask — Main-thread tasks >50 ms. attribution links to script URL and container when supported — the fastest path from “INP regressed” to “this bundle line.”
  • resource — Per-asset timing: DNS, connect, TTFB, download. Pair with preload and preconnect experiments.
  • navigation — Document navigation timing (deprecated in favor of Navigation Timing Level 2 via performance.getEntriesByType('navigation')[0], but still observable).
  • paintfirst-paint and first-contentful-paint for lab comparisons.
  • mark / measure — Custom instrumentation around component mount, data fetch, or route transitions.

Buffered observers and early registration

Analytics tags often load after first paint. Without buffering, you miss the LCP entry entirely. Register observers in an inline snippet in <head> or the first module your app imports — not inside a React useEffect that runs after hydration unless you accept blind spots on the first view.

const po = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
  report({ name: entry.name, value: entry.startTime, id: entry.id });
  }
});
if (PerformanceObserver.supportedEntryTypes?.includes('largest-contentful-paint')) {
  po.observe({ type: 'largest-contentful-paint', buffered: true });
}

For INP, observe event with durationThreshold: 16 (or lower in supporting browsers) to reduce noise from micro-interactions while still capturing slow taps. Debounce reporting: send the worst interaction per page view or use the web-vitals library's attribution helpers rather than POSTing every entry.

Soft navigations and SPAs

Client-side route changes do not fire a full navigation entry. Reset per-view state on route change, disconnect old observers, and re-register or use framework-specific hooks. LCP for a soft navigation is still an active area — track element timing per view with custom marks until browser support converges.

Harbor Commerce dashboard refactor

Harbor Commerce's merchant analytics dashboard mixed three concerns in one bundle: catalog tables, a D3 revenue chart, and checkout status polling. Lab tests looked fine; production INP on the order-review step crept from 140 ms to 220 ms p75 over two releases.

Instrumentation added

  1. Head inline snippet registering buffered observers for LCP, CLS, event, and longtask before the main bundle.
  2. Beacon endpoint accepting JSON with entry type, value, attribution URL, route path, and release version.
  3. Dashboard comparing p75 by route and bundle version — not just global site averages.

Findings and fixes

  • Longtask attribution pointed at tooltip CSV parsing on the chart — moved to a worker; longtask count on checkout dropped 70%.
  • LCP element was a hero product image loaded without fetchpriority="high" — fixed with preload + fetchpriority; LCP improved 380 ms on 4G.
  • Layout-shift entries showed ad slot injection resizing the sticky summary bar — reserved min-height on the slot container; CLS fell below 0.05.
  • Event entries on the Pay button revealed 90 ms input delay from a document-level click listener doing synchronous JSON.stringify — deferred to requestIdleCallback.

Splitting the chart into a lazy route chunk via dynamic import further reduced main-thread work on pages that never open analytics.

Technique decision table

Approach Captures Best when Watch out for
PerformanceObserver (hand-rolled) Any supported entry type Full control, custom beacons, attribution enrichment Metric spec drift; must update when Google changes INP/LCP rules
web-vitals library LCP, INP, CLS + attribution Standard RUM with minimal code; Google-maintained thresholds Less flexible for custom marks; bundle size (small but non-zero)
Navigation Timing (one-shot) TTFB, DOM milestones Simple page-load dashboards No post-load interactions; misses SPA soft navs
Resource Timing poll on load Asset waterfall Waterfall charts in synthetic tests Late resources missed; no layout-shift or INP
CrUX / Search Console only Field p75 by origin SEO validation, executive reporting 28-day lag; no route-level or release-level drill-down
Full RUM vendor (Datadog, New Relic) Metrics + session replay Enterprise SLOs, cross-service tracing Cost, script weight, privacy review for replay

Common pitfalls

  • Late registration without buffered — Missing first LCP makes every optimization look ineffective.
  • Observing deprecated entryTypes arrays — Some types require the modern { type: '...' } form; mixing styles throws.
  • Reporting every resource entry — Floods analytics and adds main-thread overhead; sample or aggregate.
  • Ignoring hadRecentInput on layout-shift — Counting user-initiated shifts inflates CLS and sends engineers on wild goose chases.
  • Equating lab Lighthouse with field INP — Throttled CPU in lab exaggerates long tasks; validate with observer data from real devices.
  • Memory leaks in SPAs — Observers accumulate across routes if never disconnected.
  • PII in attribution URLs — Resource and longtask attribution can contain query strings with tokens; strip before beaconing.
  • Blocking the callback — Heavy work inside the observer callback delays the main thread; queue to sendBeacon or a worker.

Production checklist

  • Register LCP, CLS, and event observers with buffered: true as early as possible.
  • Feature-detect via PerformanceObserver.supportedEntryTypes.
  • Add longtask observer on critical flows (checkout, editor, dashboard).
  • Attach release version and route path to every beacon payload.
  • Compute p75 per route weekly; alert on >10% regression vs prior release.
  • Strip query strings and hash fragments from attribution URLs.
  • Disconnect observers on SPA route teardown.
  • Cross-check field RUM against CrUX monthly for calibration.
  • Document INP/LCP budgets in CI; fail builds on synthetic regression only as a gate, not the sole signal.
  • Review observer overhead quarterly — sampling rate vs data fidelity.

Key takeaways

  • Performance Observer delivers performance timeline entries to a callback asynchronously — the correct primitive for RUM and Core Web Vitals in production.
  • Buffered registration recovers entries recorded before your analytics script loads; put a small inline observer in the document head.
  • LCP, layout-shift, and event entry types map directly to Core Web Vitals; longtask attribution bridges metrics to specific scripts.
  • Harbor Commerce's refactor used observers to catch a chart-library regression weeks before CrUX — then fixed it with workers, resource hints, and code splitting.
  • Prefer the web-vitals library for standard reporting; drop to hand-rolled observers when you need custom marks, route-level SLOs, or release attribution.

Related reading