Skip to content

Web Vitals

The vitals subpath wires up PerformanceObserver for the five Core Web Vitals and emits one vitals event per pageview, fired on pagehide or visibilitychange to 'hidden'. Bundle target: under 1 kB gzip.

import { installVitals } from '@syntarie/tracking/vitals';
import { send } from '@syntarie/tracking'; // helper exposed for module composition
const teardown = installVitals(send);
// On hot-reload or when tearing down the host page:
teardown();

installVitals returns a function that disconnects every observer. Call it in your bundler’s HMR hook to avoid duplicated observers.

MetricSource
LCPLargest Contentful Paint, via the 'largest-contentful-paint' PerformanceObserver.
FCPFirst Contentful Paint, via the 'paint' observer where name === 'first-contentful-paint'.
CLSCumulative Layout Shift, the sum of 'layout-shift' entries excluding user input.
INPInteraction to Next Paint, via the 'event' observer, max interaction duration.
TTFBTime To First Byte, responseStart - startTime from the navigation entry.

By default, the vitals event fires on pagehide, once per real page load. For client-side route changes you want a fresh per-route measurement:

import { endVitalsForPageview } from '@syntarie/tracking/vitals';
// Inside your router's beforeEach hook:
router.beforeEach(() => {
endVitalsForPageview();
});

endVitalsForPageview flushes the current accumulator and resets it for the next route. The next user-visible paint after the route change becomes the new LCP and FCP for that route.

The module is a no-op on browsers without PerformanceObserver. It does not throw and does not log a warning. Older browsers simply do not contribute vitals data.

{
"type": "vitals",
"url": "https://example.com/pricing",
"ts": 1714665600000,
"props": {
"lcp_ms": 1432,
"fcp_ms": 612,
"cls": 0.04,
"inp_ms": 184,
"ttfb_ms": 121
}
}

Missing metrics (e.g. INP on a navigation with no interaction) are simply omitted from props. The query API returns counts and per-day aggregates over your vitals events.