# Real-user web vitals

> Measure LCP, INP, CLS, FCP and TTFB in your visitors' browsers with @belvador/browser: off by default, sampled, consent-aware, paths without ids, p75 per route on your own node.

Web vitals tell you how fast your pages feel to the people using them: measured in their browsers, on their devices and networks, not in a lab. Belvador's browser SDK collects them from version 0.7.0; they are kept on your own node like everything else and shown under **Metrics → Web vitals**.

They are **off by default**. Nothing about web vitals is loaded or sent until you turn them on.

## Turn them on

```ts title="src/instrument.ts"
import { init } from "@belvador/browser"; // or @belvador/react, @belvador/tanstack-start/client

init({
  dsn: "https://<public-key>@ingest.belvador.dev/<project-id>",
  // Real-user LCP, INP, CLS, FCP and TTFB from 10 % of page loads.
  webVitals: { sampleRate: 0.1 },
});
```

Works the same with `@belvador/react` and `@belvador/tanstack-start/client`. Your workspace node needs agent 0.1.40 or newer (nodes update themselves); until then the Web vitals tab says the node has to update first.

| Option | Default | What it does |
|---|---|---|
| `webVitals` | off | `true` or an object turns web vitals on. |
| `sampleRate` | 10 % of page loads | Share of page loads measured, `0` to `1`. The choice is made once per page load, before anything is downloaded. |
| `consent` | not set | A function asked before every send. Anything but `true` — or an error it throws — sends nothing. |
| `respectGpc` | `true` | While the browser signals [Global Privacy Control](https://globalprivacycontrol.org/), nothing is sent. |
| `route` | your router's routes with `withBelvadorRouter`, else the path with ids replaced | A function from the path to its route template (`/w/acme/settings` → `/w/:slug/settings`). No answer or an error sends `/:unmatched`. |

### With a consent banner

```ts title="src/instrument.ts"
import { init } from "@belvador/browser";

init({
  dsn: "https://<public-key>@ingest.belvador.dev/<project-id>",
  webVitals: {
    sampleRate: 0.1,
    // Asked before every send: nothing leaves until your consent banner says yes.
    consent: () => window.localStorage.getItem("analytics-consent") === "granted",
  },
});
```

`consent` is asked again before every send, so a visitor who says yes halfway through a visit is measured from then on, and one who withdraws is not measured again.

## What is measured

The SDK uses Google's [`web-vitals`](https://github.com/GoogleChrome/web-vitals) library (attribution build). Each vital is shown at the 75th percentile (p75) of its samples and rated with the [web.dev](https://web.dev/articles/vitals) thresholds:

| Vital | What it measures | Good | Needs improvement | Poor | Metric name |
|---|---|---|---|---|---|
| LCP | Largest contentful paint: when the main content has shown | ≤ 2.5 s | ≤ 4 s | > 4 s | `browser.web_vital.lcp` |
| INP | Interaction to next paint: how long the page takes to answer a click, tap or key | ≤ 200 ms | ≤ 500 ms | > 500 ms | `browser.web_vital.inp` |
| CLS | Cumulative layout shift: how much the layout jumps | ≤ 0.1 | ≤ 0.25 | > 0.25 | `browser.web_vital.cls` |
| FCP | First contentful paint: when anything has shown | ≤ 1.8 s | ≤ 3 s | > 3 s | `browser.web_vital.fcp` |
| TTFB | Time to first byte: when the server started answering | ≤ 800 ms | ≤ 1.8 s | > 1.8 s | `browser.web_vital.ttfb` |

LCP, CLS and INP are the Core Web Vitals. CLS and INP are final only when the page is hidden or closed, so they arrive then; LCP, FCP and TTFB arrive as soon as they are known. Not every browser reports every vital (see the library's [browser support](https://github.com/GoogleChrome/web-vitals#browser-support)) — page loads in such browsers simply add fewer samples.

## What leaves the page

Each measurement is one Sentry `log` item with `body = "browser.web_vital"` on your project's browser DSN, with these attributes only:

| Attribute | Example | Notes |
|---|---|---|
| `browser.web_vital.name`, `.value`, `.delta`, `.id`, `.rating` | `LCP`, `2480` | Your node computes the rating again from the value; the page's own rating is not trusted. |
| `url.path` | `/posts/:id` | The route template when one is known (see `route` above), else the path only: no origin, query or fragment, with numbers, UUIDs, long hex strings, opaque tokens and e-mail addresses in a segment replaced by `:id`. At most 200 characters. |
| `user_agent.original` | `Chrome 131` | Browser and major version, never the full user-agent string. |
| `navigation.type` | `navigate`, `reload`, `back-forward` | From the browser. |
| `sentry.environment`, `sentry.release` | `production`, `web@1.4.0` | The `environment` and `release` you pass to `init`, so measurements land in the same environment as the page's errors. |
| `browser.web_vital.attribution.target` | `main > img.hero` | LCP element, INP interaction target or CLS largest shift target, as a CSS selector cut to 100 characters. |

`/posts/123?token=abc#x` is sent as `/posts/:id`; `token=abc` never leaves the page. The log then goes through the same privacy rules as every other log on your node (see [Privacy](https://belvador.dev/en/docs/privacy)), before anything is stored. LCP, FCP and TTFB use the route the page was loaded on; INP and CLS the route shown when the page was hidden, so single-page apps attribute them to the screen the visitor was on.

Words in a path are kept as written: without a route template, `/w/acme/settings` stays `/w/acme/settings`, so customer, team or project names in your paths would be stored with every measurement. Send the route instead:

- **TanStack Start / TanStack Router** — with `createRouter(withBelvadorRouter({ routeTree, … }))` the SDK names the route from your route tree from version 0.9.2: `/w/acme/settings` is sent as `/w/:slug/settings`. Nothing else to set.
- **Any other app** — pass `webVitals: { route: (path) => … }` and return the template, for example from your router's matched route.

When a route function answers nothing (no route matches) or throws, the measurement is sent with the path `/:unmatched` — never the visited path.

Measurements of one page load are sent together — a second after the first one, or as soon as five are waiting — and, when the page is hidden or closed, at once with `keepalive`. They are never kept for later: web vitals do not use the [offline queue](https://belvador.dev/en/docs/sdk/offline), because an old measurement is worth less than a fresh one.

## Size and speed

Turning web vitals off adds no web-vitals code: the release gate checks that the main bundle does not contain the library. When they are on, the library and the SDK's web-vitals code arrive as their own file of about 6 KB gzip, downloaded after `init` only on the page loads that are sampled. Measuring does not block the page: the browser reports each value through its performance observers, and the SDK sends a small batch a second later, or at once when the page is hidden.

## On your node

Each measurement is kept as a log, and your node also adds it to a histogram metric — `browser.web_vital.lcp`, `.inp`, `.cls`, `.fcp`, `.ttfb` — keyed by `url.path` and rating. A project keeps at most 200 routes as their own series (counted over 24 hours); measurements on further routes are counted under the route `other`, while their logs keep the real path. Each measurement counts toward the project's log volume.

**Metrics → Web vitals** shows, for one project, environment and window (up to 7 days):

- the p75 of LCP, INP and CLS with their rating, FCP and TTFB beside them, and the share of good samples;
- the distribution of one vital, bucket by bucket, coloured by rating;
- the elements behind samples that were not good, most frequent first;
- the busiest routes with their p75 — select a route to see only its numbers.

Below 100 samples a p75 is labelled **estimate**: with few samples one slow visit moves it a lot.

### Alerts

The metrics are ordinary metrics, so a [metric monitor](https://belvador.dev/en/docs/otel/metrics#alert-on-a-metric) can watch them — for example `browser.web_vital.lcp`, aggregation `p75`, filter `url.path = /checkout`, above 2500 for 15 minutes. **Create monitor** on the Web vitals tab fills in the vital and route for you.

### API and AI agents

The same summary is available over the [REST API](https://belvador.dev/en/docs/reference/api) with a `metrics:read` token:

```bash
curl -s "https://api.belvador.dev/v1/projects/<project-id>/web-vitals?environment=production&path=/posts/:id"   -H "Authorization: Bearer $BELVADOR_TOKEN"
```

AI agents can read the histograms with the MCP tool `query_metrics` (aggregation `p75`). [Coverage](https://belvador.dev/en/docs/coverage) can expect web vitals from a browser surface; the cell turns verified when your node has stored a measurement in that environment.

## Turn them off

Remove `webVitals` (or set `sampleRate: 0`) and deploy. Measurements already stored stay until the project's log and metric retention removes them.
