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#
import { init } from "@belvador/browser"; // or @belvador/react, @belvador/tanstack-start/clientinit({ 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, 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#
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 library (attribution build). Each vital is shown at the 75th percentile (p75) of its samples and rated with the web.dev 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) — 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), 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/settingsis 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, 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 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 with a metrics:read token:
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 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.
Spotted something wrong or missing? Tell us: hello@belvador.dev