PerformanceObserver API

TL;DR / PerformanceObserver provides a callback-based API for observing performance entries (LCP, FID, CLS, long tasks, resource timing) as they occur, replacing the polling-based performance.getEntries() approach.

How It Works

 ┌────────────┐        ┌──────────────┐        ┌──────────────┐        ┌────────────┐
 │  Browser   │───┐    │ Performance  │        │ Performance  │        │  Callback  │
 └────────────┘   └───→│   Entries    │───────→│   Observer   │───────→│ (entries)  │
                       └──────────────┘        └──────────────┘        └────────────┘


 entryTypes:

 ┌───────┐   ┌───────┐   ┌───────┐   ┌────────────┐   ┌────────────┐
 │  LCP  │   │  FID  │   │  CLS  │   │  longtask  │   │  resource  │
 └───────┘   └───────┘   └───────┘   └────────────┘   └────────────┘

Edit diagramLLqoIABKACIA0gCKAAriAKzuSAC2ACxB+ADWSAC0UQBC7AD6ABZqICTizBD0uAhu2Oa0JCWo7MnYRRBwokUkaIzYNVDOUArlrFWB8Eii46XeuBBGgblm5jDQILbUASAAskFQyi5BslGFsQBmAI6MWYwuBfi8AFKlGlo6aPqGxrGmFn1bCAHE5XMYvD4-DtQpEYvEkqkMtk8oUSthypV6JwoKtShAnFFNHBtMgviADEt0G0QLjUIxrFsGDt0gA5Fw0EJRXjKXAAYW4kiQUHYAE5mM8ADLiSzqIkk3TfSl-GkAlpAkHONwYCG+KD+egw6JxBIpNKZHL5YqlDETEA1OogBo0JrGABs7U63V6qGVg2Go3Es0mcGmswhCyWICi0AuCCgiU4SCIEAAOkgAKJIEhQOAQciM2j0AS8USSLJIfBFABiCnc7BgvCINF4vKydgA7G85Z89OSfqhkiYVZY6erHJq+jqoQbwkb4aakRbUdaKrbsbjsPiaISPqTexS0PF-iP6QWdokXLxcmKrsJ0lAJWERQAVAAcUHQSCrJBZXd3Cr7JZYiHWl2DHUEtWpbxdX1QJDThE1EXNFErXRVdqlqUonRdFp3RADouh6Q8Bjjf0xltKYZiqcNFnoaMoFjeNE2TNNpFwdYoEgKBNkwbZ6FEAQggKGB23QKAIF4Xh2CQV12yrdMAAkoAUzg-2JHtFTQWTj0BexxzBH0pz1aFZwQhEzWRS00TKdDAnXAFNwJbs9001B2zw2lT14pl6AALV5FwADUWWedwCgzVhEiyFl2BCCpuBIZ81PlMkDzc9sdLVPSIK0ozYOCUzjXMxcUOsm0MIdbDmlaD1CO9X1SOgAMgxASiwxAeZaMCXlOFYVhcE4Ih0jTAAKfFs1zGAAEoeL4wIwAEOx8BoV1n14aQuFYV1cFfIIkHYXywCIZKNMAqxlVpdtwInNy8pM2EioXZCrJXTFAhIVxemwAws3G+hxqgcRn1XGBAjPegwCyRJdglAVzHYdJxG4XAJSKIIIggdAyxOlyzpaTKRWugzXzumcHvnJDLOXNC3rtTD6kaZoRVqr1JzpoYmvI+g2uojrOAjegJV5KJZp8wIFKIOwSAKRgrhIdxYkSCUBHEEheSKGhpAATWSHGALS9h+mHNBCeym7mb5mD7rnRCLKXVCbNp+0sMZk2WaIukSI5kYueDUNec6yMq1kEJRcLQIIl2Kt0mTSROFdXkQlkdttCiIoYGbUQ9dS-t0Au1VTeBfStXYTxLenODCopu3Ste21nYZ50mfd70jb9TnAwokMqLmfmupAXkJQUMOdiIdhRF4GAXAiZ8aHTGB8GeIowHSVaBECuhZX-HOlkYDLjdQQuNQM9gh2giuCvJ22Spemn6-px1XZaLB8M9D228an3O+57v2sD+hWDIFECQTgMB0gj3oJIQoChhA0AiHYXkQkIBBEYK6FkLJJDMAlFcbO+5+yxBfrSI+xdjAv3PsZMmNtirPWpo7e+lUn7sBfgRVmnt2ZkW-n7HuNFIziRgAgAQUBkybAALpGXIKgKgYtL5UKelTB2SBOCJFtBKSEUAAAE7BShgDgPAXAQZswCAgF4BAQ0IABAuL1dY1gRHWCAA)

The PerformanceObserver API is the modern way to collect performance metrics in the browser. Rather than polling performance.getEntries() on a timer (which misses entries and wastes CPU), PerformanceObserver delivers entries to your callback as they are recorded, using the observer pattern.

Construct an observer with a callback, then call observe() specifying which entry types to watch. The callback receives a PerformanceObserverEntryList containing all entries recorded since the last callback invocation. Each entry is a PerformanceEntry subclass with type-specific properties.

There are two observation modes. The entryTypes option subscribes to multiple types with a single observer: observer.observe({ entryTypes: ['resource', 'longtask'] }). The type option subscribes to a single type but enables the buffered: true flag, which delivers entries that were recorded before the observer was created. This is critical for metrics like LCP that fire during initial page load -- your analytics script may load after the LCP entry was recorded.

Largest Contentful Paint entries (type: 'largest-contentful-paint') report the render time of the largest image or text block in the viewport. Multiple entries may fire as larger elements render; the last one before user interaction is the final LCP value. Each entry includes startTime, renderTime, loadTime, size, and the element reference.

First Input Delay entries (type: 'first-input') contain a single entry with processingStart - startTime representing the delay between the user's first interaction and the browser's ability to handle it. This metric is being superseded by INP but remains widely reported.

Layout Shift entries (type: 'layout-shift') fire whenever visible elements move unexpectedly. Each entry has a value (the layout shift score for that frame) and hadRecentInput (true if the shift was within 500ms of user input, which means it should be excluded from CLS calculation). CLS aggregation requires implementing the session window algorithm: group shifts into windows with a maximum 5s duration and 1s gap between shifts, then take the window with the largest total score.

Long Task entries (type: 'longtask') fire for any JavaScript task exceeding 50ms. The entry provides startTime, duration, and an attribution array identifying the culprit container (iframe, script origin). Long tasks directly correlate with input delay and jank.

Resource Timing entries (type: 'resource') provide detailed timing for every resource fetched by the page: DNS lookup, TCP connect, TLS handshake, request/response times, transfer size, and cache status. This is the data source for waterfall diagrams and resource optimization analysis.

Event Timing entries (type: 'event') power INP measurement. They report processingStart, processingEnd, startTime, and duration for input events exceeding 104ms. Your code groups these by interactionId and tracks the worst-case interaction duration across the session.

Navigation Timing entries (type: 'navigation') replace the deprecated performance.timing API. A single entry reports the full page load waterfall: redirect time, DNS, TCP, request, response, DOM processing, and load event timing.

The observe() call with { type: 'x', buffered: true } is essential for Core Web Vitals collection. Without buffered, entries recorded before your script executes are invisible. The web-vitals library from Google uses this pattern internally, creating separate observers for each metric type with buffered observation.

Call observer.disconnect() to stop observing. Call observer.takeRecords() to synchronously retrieve any pending entries before disconnecting, ensuring nothing is lost. The callback fires asynchronously (microtask timing), so takeRecords() is necessary in scenarios like beforeunload where you need entries immediately.

For RUM (Real User Monitoring) systems, PerformanceObserver entries are typically batched and sent via navigator.sendBeacon() on visibilitychange (not unload, which is unreliable on mobile). The sendBeacon API guarantees delivery even as the page is being torn down.

Gotchas

  • buffered: true only works with the single type syntax -- observe({ entryTypes: ['largest-contentful-paint'], buffered: true }) silently ignores the buffered flag; use observe({ type: 'largest-contentful-paint', buffered: true }) instead
  • Not all entry types are supported in all browsers -- longtask has no Firefox support; event timing support varies by browser; feature-detect with PerformanceObserver.supportedEntryTypes
  • LCP entries stop after user interaction -- the final LCP value is the last entry before the first tap, scroll, or keypress; if your page loads content after interaction, it is not reflected in LCP
  • Layout shift value is not CLS -- CLS requires the session window algorithm across multiple entries; summing all layout shift values without windowing produces an inflated, incorrect score
  • Observer callbacks are batched, not per-entry -- the callback may receive multiple entries at once; always iterate entryList.getEntries() rather than assuming a single entry