# PerformanceObserver and paint timing

> How PerformanceObserver delivers paint, largest-contentful-paint, and navigation entries, which engines report which, and what buffered and workerStart mean.

`PerformanceObserver` subscribes to entries the browser appends to the performance timeline as
they happen: `paint` entries for the first pixels and first text or image, `largest-contentful-paint`
for the biggest element, and the single `navigation` entry whose milestones cover the request,
the service worker, and the document events. Together they are the start-up measurement for a
PWA, and they are the same data the `web-vitals` library reads.

## Syntax

```js
const observer = new PerformanceObserver(callback)
observer.observe({ type, buffered })
observer.observe({ entryTypes })
observer.disconnect()
observer.takeRecords()
PerformanceObserver.supportedEntryTypes
performance.getEntriesByType(type)
```

The callback receives a `PerformanceObserverEntryList` and the observer. `observe()` takes either
one `type` with options or an `entryTypes` array without. `PerformanceObserver` is in Chrome 52,
Firefox 57, and Safari 11; `supportedEntryTypes` in Chrome 73, Firefox 68, Safari 13 (BCD
`api.PerformanceObserver`).

## Parameters

| Parameter | Type | Meaning |
|---|---|---|
| `callback` | function | Called with the new entries, batched per task. |
| `type` | string | One entry type: `"paint"`, `"navigation"`, `"largest-contentful-paint"`, `"event"`, `"layout-shift"`, `"long-animation-frame"`, `"resource"`, and others. Unknown types are ignored without error. |
| `buffered` | boolean | Deliver entries recorded before `observe()` was called, up to the engine's per-type buffer. Required for paint and navigation entries, which are recorded before most scripts run. Only valid with `type`. |
| `entryTypes` | array of strings | Several types at once, without `buffered`. |

The entries differ by type. A `paint` entry has `name` (`"first-paint"` or
`"first-contentful-paint"`), `startTime`, and a `duration` of `0`. A `largest-contentful-paint`
entry adds `element`, `size`, `url`, and `renderTime`, and is emitted again each time a larger
candidate paints until the first input. The one `navigation` entry carries the Navigation Timing
milestones: `workerStart` (non-zero when a service worker handled the navigation), `fetchStart`,
`responseStart`, `domInteractive`, `loadEventEnd`, `type` (`"navigate"`, `"reload"`,
`"back_forward"`, `"prerender"`), and `activationStart`, the time a prerendered document was
shown, against which its other timestamps are adjusted.

## Exceptions

| Exception | When |
|---|---|
| `TypeError` | `observe()` is called with both `type` and `entryTypes`, or with neither, or `buffered` is passed with `entryTypes`. |
| `TypeError` | The callback is not callable. |
| `SyntaxError` `DOMException` | `entryTypes` is present but empty. (Some engines log a warning instead and observe nothing.) |

Engine gaps are not exceptions: `first-paint` is reported by Chromium only (BCD
`api.PerformancePaintTiming.first-paint`: Firefox and Safari `false`), `layout-shift` and
`long-animation-frame` are Chromium only, and `largest-contentful-paint` arrived in Firefox 122
and Safari 26.2. An observer for an unsupported type simply never fires.

## Support by engine

`PerformancePaintTiming` is in Chrome 60, Firefox 84, and Safari 14.1, and
`PerformanceNavigationTiming` in Chrome 57, Firefox 58, and Safari 15 (BCD), so both are usable
everywhere a PWA runs, with the per-type gaps listed above. `paintTime` and `presentationTime` on
paint entries, which separate the render from the display of the frame, are in Chrome 145 and
Firefox 140 and absent in Safari. `notRestoredReasons` on the navigation entry is Chrome 125.

## Examples

The examples collect the start-up entries, correct them for prerendering, and degrade without the observer.

### Recording the start-up milestones

One observer per type, each with `buffered: true`, collects everything the browser recorded
before the script ran. The LCP handler keeps the latest candidate and reports once the user
interacts.

```js
const report = {};

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) report[entry.name] = entry.startTime;
}).observe({ type: 'paint', buffered: true });

new PerformanceObserver((list) => {
  const last = list.getEntries().at(-1);
  if (last) report.lcp = last.startTime;
}).observe({ type: 'largest-contentful-paint', buffered: true });

new PerformanceObserver((list) => {
  const [nav] = list.getEntries();
  report.ttfb = nav.responseStart;
  report.workerBoot = nav.workerStart > 0 ? nav.fetchStart - nav.workerStart : 0;
}).observe({ type: 'navigation', buffered: true });

addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') navigator.sendBeacon('/metrics', JSON.stringify(report));
}, { once: true });
```

Sending on `visibilitychange` rather than `unload` keeps the page eligible for the
[back/forward cache](/reference/performance/bfcache/).

### Adjusting for a prerendered start

A prerendered page's timestamps start when prerendering began, not when the user saw it.
Subtract `activationStart` so a prerendered visit is comparable with a normal one.

```js
function visibleStart(entry) {
  const [nav] = performance.getEntriesByType('navigation');
  const activation = nav?.activationStart ?? 0;
  return Math.max(entry.startTime - activation, 0);
}
```

A `first-contentful-paint` that happened during prerendering reports as `0` after adjustment,
which is the user's experience of it.

### Detecting the observer and reading the buffer directly

`performance.getEntriesByType()` returns what is already on the timeline, which is enough for
paint and navigation entries in an engine without `PerformanceObserver`.

```js
function paintTimings() {
  if ('PerformanceObserver' in window
      && PerformanceObserver.supportedEntryTypes.includes('paint')) {
    return new Promise((resolve) => {
      new PerformanceObserver((list) => resolve(list.getEntries())).observe({ type: 'paint', buffered: true });
    });
  }
  if (!('performance' in window) || typeof performance.getEntriesByType !== 'function') {
    return Promise.resolve([]); // no timeline at all: report nothing
  }
  return Promise.resolve(performance.getEntriesByType('paint'));
}
```

The direct read misses a paint that happens after the call, which for the two paint entries is
only a concern for scripts that run before the first paint.

:::observed
`performance.getEntriesByType('paint')` in Chrome's Console returns two entries, `first-paint`
and `first-contentful-paint`, each with `entryType: "paint"` and `duration: 0`; the same call in
Firefox and Safari returns one entry, `first-contentful-paint`, because neither reports the
optional first paint (BCD `api.PerformancePaintTiming.first-paint` in
[PerformancePaintTiming.json](https://github.com/mdn/browser-compat-data/blob/main/api/PerformancePaintTiming.json),
github.com). A script keyed on `first-paint` therefore waits forever outside Chromium; keying on
`first-contentful-paint` works in all three.
:::

## See also

- [Performance Timeline](https://w3c.github.io/performance-timeline/) (w3.org)
- [PerformanceObserver](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver) (developer.mozilla.org)
- [PerformanceNavigationTiming](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigationTiming) (developer.mozilla.org)
- [Core Web Vitals (LCP, INP, and CLS)](/reference/performance/core-web-vitals/)
- [Navigation preload and service worker boot time](/reference/performance/navigation-preload/)
- [Back/forward cache (bfcache)](/reference/performance/bfcache/)