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
- Construct —
new PerformanceObserver(callback)receives aPerformanceObserverEntryList. - Observe —
observer.observe({ type, buffered })or legacyentryTypes: ['paint', 'navigation'](mutually exclusive withtypein modern browsers). - Callback — Process
list.getEntries(); entries are immutable snapshots. - Disconnect —
observer.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. IncludesrenderTime,loadTime,size, andelementattribution when available.layout-shift— Each unexpected shift withvalue(fraction of viewport) andhadRecentInput. Sum shifts without recent input for CLS; session windows apply in the full metric definition.event— Interaction entries for INP:duration,interactionId, andprocessingStart/processingEndbreak down input delay, processing, and presentation delay.
Diagnostics
longtask— Main-thread tasks >50 ms.attributionlinks 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 viaperformance.getEntriesByType('navigation')[0], but still observable).paint—first-paintandfirst-contentful-paintfor 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
- Head inline snippet registering buffered observers for LCP, CLS, event, and longtask before the main bundle.
- Beacon endpoint accepting JSON with entry type, value, attribution URL, route path, and release version.
- 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
hadRecentInputon 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
sendBeaconor a worker.
Production checklist
- Register LCP, CLS, and event observers with
buffered: trueas 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
- Core Web Vitals explained — LCP, INP, CLS thresholds and fixes
- Resource hints explained — preload, preconnect, and fetchpriority
- Code splitting explained — dynamic imports and smaller initial bundles
- Web Workers explained — offloading CPU work off the main thread