[invisit](https://invisit.dev) · [Docs](https://invisit.dev/docs) · [llms.txt](https://invisit.dev/llms.txt) · [llms-full.txt](https://invisit.dev/llms-full.txt)

# How to measure Core Web Vitals from real users (CrUX vs RUM)

> CrUX vs your own RUM, why PageSpeed Insights has no field data for a page, tested web-vitals code, and what six tools charge for vitals.

- Author: Miraç Berk
- Published: 2026-11-03
- Page: https://invisit.dev/blog/real-user-web-vitals
- Tags: web-vitals, performance, rum

![A soft 3D stopwatch beside three gauge dials marked good, needs improvement and poor, lit by violet dithered light with soft shadows.](https://invisit.dev/_astro/cover.r590aVi2.png)

You can measure Core Web Vitals from real users in two ways: read Chrome's own field data, the Chrome User Experience Report (CrUX) behind PageSpeed Insights and Search Console, or collect it yourself with real user monitoring (RUM), usually through Google's `web-vitals` library. CrUX is free but covers only Chrome users on public pages with enough traffic, over a rolling 28 days. RUM covers every page and browser you instrument.

Either way, read the 75th percentile of page loads, split by mobile and desktop: LCP at or under 2.5 seconds, INP at or under 200 ms, CLS at or under 0.1. Below: the thresholds, what CrUX leaves out, code we ran in Chrome 155, four pitfalls (one currently drops INP in `web-vitals` 6.2.3), and what six tools charge for vitals, as of November 2026.

## What is field data, and how is it different from Lighthouse?

Field data is measured in your visitors' browsers during real visits. Lab data comes from one simulated load. PageSpeed Insights shows both: on top, CrUX field data from the previous 28 days; below, a Lighthouse run on an emulated mid-tier phone over a throttled network ([PageSpeed Insights docs](https://developers.google.com/speed/docs/insights/v5/about)). [Our script size post](/blog/analytics-script-size) has the throttling numbers.

They disagree for structural reasons. Lighthouse cannot measure INP, because nobody clicks during a lab run, so it shows Total Blocking Time instead ([web.dev](https://web.dev/articles/vitals)). A lab run ends with the load, while real CLS and INP count for as long as the page is open. The Core Web Vitals assessment uses the field data: use the lab run to find causes and field data to judge results.

## What are the Core Web Vitals thresholds?

The Core Web Vitals are Largest Contentful Paint (LCP) for loading, Interaction to Next Paint (INP) for responsiveness and Cumulative Layout Shift (CLS) for visual stability; INP replaced First Input Delay in 2024 ([web.dev](https://web.dev/articles/vitals)). First Contentful Paint (FCP) and Time to First Byte (TTFB) help explain a slow LCP.

| Metric | What it measures                                                      | Good           | Needs improvement | Poor        |
| ------ | --------------------------------------------------------------------- | -------------- | ----------------- | ----------- |
| LCP    | When the largest image or text block in the viewport renders          | 2.5 s or less  | 2.5 to 4 s        | Over 4 s    |
| INP    | Time from a click, tap or key press to the next frame, near the worst | 200 ms or less | 200 to 500 ms     | Over 500 ms |
| CLS    | How far visible content moves without the visitor asking              | 0.1 or less    | 0.1 to 0.25       | Over 0.25   |
| FCP    | When the first text or image renders                                  | 1.8 s or less  | 1.8 to 3 s        | Over 3 s    |
| TTFB   | When the first byte of the HTML arrives                               | 0.8 s or less  | 0.8 to 1.8 s      | Over 1.8 s  |

Thresholds come from [web.dev](https://web.dev/articles/defining-core-web-vitals-thresholds) and, for FCP and TTFB, the [PageSpeed Insights docs](https://developers.google.com/speed/docs/insights/v5/about), which label TTFB experimental. A page passes when all three Core Web Vitals are good at the 75th percentile, or LCP and CLS alone when there is too little INP data.

### Why the 75th percentile?

Google chose the 75th percentile so that most visits get the target experience while a few outliers cannot decide the result. On a page with 100 visits, 5 slow outliers move the 95th percentile; moving the 75th takes 25 ([web.dev](https://web.dev/articles/defining-core-web-vitals-thresholds)). Thresholds are the same for phones and desktops, but Google recommends reading the 75th percentile for each separately ([web.dev](https://web.dev/articles/vitals)), since a fast desktop audience can hide a slow mobile one. Never use the mean: one 30-second load on a broken connection drags it far and barely moves the 75th percentile.

## CrUX vs RUM: what does Chrome's report leave out?

CrUX is Chrome's measurement of opted-in Chrome users, aggregated per page and per origin, and its eligibility rules decide whose visits count ([CrUX methodology](https://developer.chrome.com/docs/crux/methodology)):

- Browsers: Chrome on desktop and Android only. Chrome on iOS, Android WebViews, Edge, Safari and Firefox send nothing.
- Users: only those with usage statistics reporting and history sync on and no sync passphrase. Chrome does not publish what share that is.
- Pages: publicly discoverable (status 200, no `noindex`) and above a popularity threshold that is not disclosed. Query strings and fragments are stripped.
- Time: a rolling 28-day window everywhere, refreshed daily in PageSpeed Insights, weekly in the History API and monthly in BigQuery ([web.dev](https://web.dev/articles/crux-and-rum-differences)).

CrUX also sees what JavaScript cannot: iframe content counts toward LCP and CLS, and background-tab loads are left out ([web.dev](https://web.dev/articles/crux-and-rum-differences)). RUM will never match it exactly; compare Chrome only, one device type, 75th percentile, 28 days.

|             | CrUX                                            | Your own RUM                                                        |
| ----------- | ----------------------------------------------- | ------------------------------------------------------------------- |
| Who         | Opted-in Chrome users, desktop and Android      | Every browser running your script; CLS from Chromium only           |
| Which pages | Public pages above an undisclosed traffic level | Every page you instrument, including logged-in pages                |
| Window      | Rolling 28 days                                 | Any range                                                           |
| Detail      | Distributions and 75th percentiles              | One row per page view, with elements and sub-parts if you send them |
| Cost        | Free                                            | Free to build; hosted tools charge in their own units               |

The `web-vitals` 6.2.3 README lists LCP, INP, FCP and TTFB as measurable in Chromium, Firefox and Safari, and CLS in Chromium only ([README](https://github.com/GoogleChrome/web-vitals#browser-support)).

### Why does PageSpeed Insights have no field data for my page?

PageSpeed Insights has no field data for a URL when CrUX has too few eligible visits to that page over the last 28 days. It then falls back to the origin's data, covering every page on the site, and if the origin is below the threshold too, it shows none ([PageSpeed Insights docs](https://developers.google.com/speed/docs/insights/v5/about)). Search Console reads the same dataset and has the same gaps. The usual causes:

- The page is new, or too few visitors are eligible Chrome users. A mostly-iPhone audience can bring plenty of traffic and little CrUX data.
- The page, or for origin data the root page, returns a status other than 200 or carries `noindex` in a meta tag or `X-Robots-Tag` header.
- Traffic is spread over many pages, so the origin qualifies but no single page does.

You cannot submit pages to CrUX. For a page it does not cover, the only field data you will get is your own.

## How to collect Core Web Vitals yourself with the web-vitals library

Google's `web-vitals` library measures each metric the way Chrome reports it to CrUX. Version 6.2.3, released October 5, 2026, is current on npm. The README puts the standard build at about 3 KB brotli-compressed and the attribution build, which adds debugging detail, at about 1.5 KB more; we measured the full bundles at 3,034 and 5,026 bytes. It reads buffered performance entries, so it can load last without missing early metrics.

```bash title="Terminal"
npm install web-vitals
```

The client below collects all five metrics with the attribution build and sends one beacon when the page is hidden. Import it once from your client entry; bundled and minified, ours was 6,097 bytes gzipped.

```ts title="src/vitals.ts"
import { onCLS, onFCP, onINP, onLCP, onTTFB } from 'web-vitals/attribution'
import type { MetricWithAttribution } from 'web-vitals/attribution'

const ENDPOINT = '/api/vitals'
const queue = new Map<string, Record<string, unknown>>()

// A few attribution fields per metric. The full objects hold DOM entries and are too big to send.
function debugFields(metric: MetricWithAttribution): Record<string, unknown> {
  switch (metric.name) {
    case 'LCP':
      return {
        target: metric.attribution.target,
        resource: metric.attribution.url,
        ttfb: metric.attribution.timeToFirstByte,
        loadDelay: metric.attribution.resourceLoadDelay,
        loadTime: metric.attribution.resourceLoadDuration,
        renderDelay: metric.attribution.elementRenderDelay,
      }
    case 'INP':
      return {
        target: metric.attribution.interactionTarget,
        inputDelay: metric.attribution.inputDelay,
        processing: metric.attribution.processingDuration,
        presentation: metric.attribution.presentationDelay,
        loadState: metric.attribution.loadState,
        script: metric.attribution.longestScript?.entry.sourceURL,
      }
    case 'CLS':
      return { target: metric.attribution.largestShiftTarget }
    default:
      return {}
  }
}

function queueMetric(metric: MetricWithAttribution): void {
  // TTFB is reported as 0 after a client-side route change, so keep it for full page loads only.
  if (metric.name === 'TTFB' && metric.navigationType === 'soft-navigation') return
  // Same id means an updated value for the same page view: keep only the latest.
  queue.set(metric.id, {
    id: metric.id,
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    navigationType: metric.navigationType,
    page: new URL(metric.navigationURL ?? location.href).pathname,
    ...debugFields(metric),
  })
}

function flush(): void {
  if (queue.size === 0) return
  navigator.sendBeacon(ENDPOINT, JSON.stringify([...queue.values()]))
  queue.clear()
}

addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flush()
})

const perRoute = { reportSoftNavs: true }
onLCP(queueMetric, perRoute)
// reportAllChanges works around web-vitals 6.2.3 dropping INP when the page hides
// less than a second after the slowest click (issue #805, fixed on main).
onINP(queueMetric, { ...perRoute, reportAllChanges: true })
onCLS(queueMetric, perRoute)
onFCP(queueMetric, perRoute)
onTTFB(queueMetric, perRoute)
```

What each part does:

- The queue is keyed by metric `id`: CLS and INP can be reported several times per page view, and each report replaces the last.
- `flush` runs when the page is hidden (tab switch, navigation, close), the last moment browsers reliably run your code. Never use `unload`; it keeps pages out of the back/forward cache in Firefox ([web.dev](https://web.dev/articles/bfcache)).
- `sendBeacon` survives the page closing. Keep it on your own origin: blocklists such as EasyPrivacy block cross-site beacons ([why](/blog/analytics-proxy)).
- `reportSoftNavs: true` reports client-side routes separately in Chrome 151 and later, and `navigationURL` names the URL each metric belongs to.
- `debugFields` keeps the LCP element and its four sub-parts, and the INP element, its three phases, the load state and the longest script from the Long Animation Frames API.

The endpoint is a standard `Request` to `Response` function, so it works as a Next.js route handler (export it as `POST` from `app/api/vitals/route.ts`), in `Bun.serve` or in Hono. It checks every field, drops unknown keys, caps the body and adds the device type from the User-Agent.

```ts title="server/vitals.ts"
import { appendFile } from 'node:fs/promises'

const NAMES = new Set(['CLS', 'FCP', 'INP', 'LCP', 'TTFB'])
const RATINGS = new Set(['good', 'needs-improvement', 'poor'])
const DEBUG_KEYS = [
  'target',
  'resource',
  'ttfb',
  'loadDelay',
  'loadTime',
  'renderDelay',
  'inputDelay',
  'processing',
  'presentation',
  'loadState',
  'script',
]
const MAX_BODY = 16_384

type Row = Record<string, unknown>

function clean(row: Row): Row | null {
  const { id, name, value, rating, navigationType, page } = row
  const valid =
    typeof id === 'string' &&
    id.length <= 64 &&
    typeof name === 'string' &&
    NAMES.has(name) &&
    typeof value === 'number' &&
    Number.isFinite(value) &&
    value >= 0 &&
    typeof rating === 'string' &&
    RATINGS.has(rating) &&
    typeof navigationType === 'string' &&
    navigationType.length <= 32 &&
    typeof page === 'string' &&
    page.startsWith('/') &&
    page.length <= 512
  if (!valid) return null

  const kept: Row = { id, name, value, rating, navigationType, page }
  for (const key of DEBUG_KEYS) {
    const extra = row[key]
    if (typeof extra === 'number' && Number.isFinite(extra)) kept[key] = extra
    else if (typeof extra === 'string' && extra.length <= 500) kept[key] = extra
  }
  return kept
}

export async function POST(request: Request): Promise<Response> {
  const body = await request.text()
  if (body.length > MAX_BODY) return new Response(null, { status: 413 })

  let rows: unknown
  try {
    rows = JSON.parse(body)
  } catch {
    return new Response(null, { status: 400 })
  }
  if (!Array.isArray(rows)) return new Response(null, { status: 400 })

  const userAgent = request.headers.get('user-agent') ?? ''
  const device = /Mobi|Android/i.test(userAgent) ? 'mobile' : 'desktop'
  const receivedAt = new Date().toISOString()
  const lines = rows
    .map((row) => (row && typeof row === 'object' ? clean(row as Row) : null))
    .filter((row) => row !== null)
    .map((row) => JSON.stringify({ ...row, device, receivedAt }) + '\n')

  // Swap this for an insert into your database.
  if (lines.length > 0) await appendFile('vitals.ndjson', lines.join(''))
  return new Response(null, { status: 204 })
}
```

Then the 75th percentile per metric, device and page, keeping the last value per `id` and using the nearest-rank method. Tools approximate it with histograms or digests, so expect small differences.

```ts title="scripts/p75.ts"
import { readFile } from 'node:fs/promises'

interface Row {
  id: string
  name: string
  value: number
  page: string
  device: string
  receivedAt: string
}

const rows = (await readFile('vitals.ndjson', 'utf8'))
  .split('\n')
  .filter(Boolean)
  .map((line) => JSON.parse(line) as Row)

// CLS and INP can arrive more than once per page view; the last value is the final one.
const latest = new Map<string, Row>()
for (const row of rows) latest.set(row.id, row)

const groups = new Map<string, number[]>()
for (const row of latest.values()) {
  const key = `${row.name} ${row.device} ${row.page}`
  groups.set(key, [...(groups.get(key) ?? []), row.value])
}

for (const [key, values] of [...groups].sort()) {
  values.sort((a, b) => a - b)
  const p75 = values[Math.ceil(values.length * 0.75) - 1]
  console.log(`${key}  p75=${p75}  samples=${values.length}`)
}
```

### What we tested

We ran all three files on November 3, 2026: Bun 1.4.4 served the page and endpoint, Playwright drove headless Chrome 155, and TypeScript 7.0 type-checked the code. The test page had a large headline as its LCP element, a banner inserted after 900 ms to shift the layout, a button whose click blocks the main thread for 300 ms, and a button that moves to `/pricing` with `history.pushState`.

Clicking the slow button, switching routes, clicking again and following a link sent one beacon with nine metrics: five for `/` and four for `/pricing` marked `soft-navigation` (no TTFB). INP was 304 ms both times, about 301 ms of it in event processing. Going back restored the page from the back/forward cache, and the next beacon carried a fresh set marked `back-forward-cache`, with TTFB 0 and LCP between 9 and 13 ms across our runs. One stored row, decimals trimmed:

```json title="One stored row"
{
  "id": "v6-1793723877303-3711261031323",
  "name": "INP",
  "value": 304,
  "rating": "needs-improvement",
  "navigationType": "navigate",
  "page": "/",
  "target": "#slow",
  "inputDelay": 0.1,
  "processing": 301.3,
  "presentation": 2.6,
  "loadState": "complete",
  "script": "http://localhost:47391/",
  "device": "desktop",
  "receivedAt": "2026-11-03T16:38:02.460Z"
}
```

## Pitfalls that skew real-user Core Web Vitals

Four things make RUM disagree with CrUX or with itself: when INP and CLS are sent, client-side routes, back/forward cache restores and prerendering. `web-vitals` handles the last three if you use it as above; the first needs a workaround today.

### INP and CLS are only final when the page is hidden

INP and CLS change for as long as the page is open, so the library reports them when the page is hidden, and again if it returns and they grow. Sent earlier, they are usually too low; stored as separate samples, one page view counts several times.

`web-vitals` 6.2.3 also has a bug here. It processes interactions in an idle callback that can wait up to a second, and if the page is hidden first, that INP is never reported ([issue #805](https://github.com/GoogleChrome/web-vitals/issues/805)). In our headless Chrome test the idle callback after a 300 ms click took the full second, and leaving 600 ms after the click lost INP in 10 of 10 page views with default options and 0 of 10 with `reportAllChanges: true`, the workaround above.

> [!WARNING]
> The fix, [pull request #809](https://github.com/GoogleChrome/web-vitals/pull/809), was merged on October 15, 2026, but no npm release contains it as of November 3. Clicks that start a navigation, often the slow ones, are the most likely to be lost.

### Soft navigations in single-page apps

Until Chrome 151, every metric in a single-page app belonged to the URL that loaded the document: LCP covered only the first screen, and INP and CLS piled up across every later route ([web.dev](https://web.dev/articles/vitals-spa-faq)). Since Chrome 151 the Soft Navigations API is on by default: an interaction that changes the URL and paints counts as a soft navigation, with its own LCP, INP and CLS ([Chrome docs](https://developer.chrome.com/docs/web-platform/soft-navigations)). Three catches:

- Only Chromium supports it; Safari and Firefox still give one set per document.
- Soft-navigation LCP counts only newly painted elements, so a hero image that stays put is not a candidate.
- CrUX has published soft navigation metrics as experimental since October 2026, aiming to graduate them in May 2027, and most Google tools still report without them, so per-route numbers will not match Search Console.

Page views have the same problem; see [counting page views in single-page apps](/blog/spa-page-views).

### Back/forward cache restores

A back/forward cache restore is not a page load, but CrUX counts it as a navigation, and a very fast one ([web.dev](https://web.dev/articles/crux-and-rum-differences)). RUM that skips restores looks slower than CrUX. `web-vitals` reports a fresh set after each restore, as our test showed; if you count page views yourself, listen for `pageshow` with `event.persisted` ([web.dev](https://web.dev/articles/bfcache)).

### Prerendered pages

With speculation rules, Chrome can prerender a page before the click, and measures its Core Web Vitals from activation, the moment it is shown, which often gives an LCP of 0 ([Chrome docs](https://developer.chrome.com/docs/web-platform/prerender-pages)). `web-vitals` has matched this since version 3.1.0. Code that reads performance entries directly must subtract `activationStart` itself, and view counters should wait for `prerenderingchange`, or prerenders never shown get counted.

## Which analytics tools collect Core Web Vitals, and what do they charge?

As of November 2026, these six differ less in which metrics they collect than in the statistic they show, how they treat single-page apps and what vitals cost. Sources are linked in the sections below.

| Tool                     | Metrics                                              | Turned on by                                     | Shown as                                               | SPA routes and back/forward restores                           | What vitals cost                                                                                                |
| ------------------------ | ---------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------ | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| Vercel Speed Insights    | LCP, INP, CLS, FCP, TTFB on Plus; score only on free | `@vercel/speed-insights` on a Vercel deployment  | P75 by default; P90, P95, P99                          | Hard navigations only; in Next.js, a session's first page view | Free: score only, 10,000 events per team per 30 days. Plus: $10 per project a month and $0.65 per 10,000 events |
| Cloudflare Web Analytics | LCP, INP, CLS, with LCP and INP sub-parts            | The Web Analytics beacon                         | P75 per element; P50, P90, P99 on expand               | Each route's values sent before the route changes              | Free; six months of data                                                                                        |
| PostHog                  | LCP, INP, CLS, FCP                                   | Opt-in web vitals autocapture                    | p75, p90 or p99; PostHog suggests p90                  | Not documented                                                 | Billed as events, about 30% of page views; 1M events a month free, then $0.00005 each up to 2M                  |
| Sentry                   | LCP, INP, CLS, FCP, TTFB                             | `browserTracingIntegration` with tracing sampled | p75 and a 0 to 100 Performance Score                   | Per soft navigation and per restore in SDK 11 stream mode      | Billed as spans; 5M spans a month on every plan, then $0.000002 a span on Team pay-as-you-go                    |
| GA4 with web-vitals      | Whatever you send                                    | Your own code                                    | No distributions in built-in reports; p75 via BigQuery | Your choice in code                                            | GA4 free; BigQuery daily export capped at 1M events on standard properties                                      |
| invisit                  | LCP, INP, CLS, FCP, TTFB                             | On by default, toggle per site                   | p75; API offers p50 to p99                             | One set per full page load                                     | Never billed                                                                                                    |

### Vercel Speed Insights

Speed Insights is separate from Vercel Web Analytics, with its own package and collection routes on your Vercel deployments ([quickstart](https://vercel.com/docs/speed-insights/quickstart)). The free tier shows only the Real Experience Score, for 10,000 events per team over a rolling 30 days. Plus, on Pro, adds all five metrics and breakdowns down to element selectors for $10 per project a month and $0.65 per 10,000 events ([pricing](https://vercel.com/docs/speed-insights/limits-and-pricing)).

Vercel collects on hard navigations only (in Next.js, a session's first page view) and expects 3 to 6 data points per visit ([metrics](https://vercel.com/docs/speed-insights/metrics)). If each is one billed event, 100,000 hard page loads cost $19.50 to $39 a month plus the $10 fee. More in [our Vercel comparison](/compare/vercel-analytics).

### Cloudflare Web Analytics

Cloudflare Web Analytics is free ([about](https://developers.cloudflare.com/web-analytics/about/)). Its beacon measures LCP, INP and CLS, splits LCP into four sub-parts and INP into three, and lists the five elements that hurt each metric most, at P75 ([docs](https://developers.cloudflare.com/web-analytics/data-metrics/core-web-vitals/)). The [FAQ](https://developers.cloudflare.com/web-analytics/faq/) gives the limits: six months of data, only the last 7 days unsampled, no custom events or UTM parameters. For free element-level debugging of field vitals, nothing here beats it.

### PostHog

PostHog's web vitals autocapture is opt-in and records FCP, LCP, INP and CLS as `$web_vitals` events ([docs](https://posthog.com/docs/web-analytics/web-vitals)). The dashboard recommends p90 and headlines the last day's value, so switch to p75 before comparing with CrUX. The events are billed like any other, about 30% of your page view count by PostHog's estimate, sharing the free 1M a month and then $0.00005 each up to 2M ([pricing](https://posthog.com/pricing), [our comparison](/compare/posthog)).

### Sentry

Sentry collects vitals as part of tracing, through `browserTracingIntegration`. Its dashboard shows p75 per page and a Performance Score weighted LCP 30%, INP 30%, CLS 15%, FCP 15% and TTFB 10%, with stricter desktop anchors than Google's (LCP 1,200 ms scores 90) ([docs](https://docs.sentry.io/product/dashboards/sentry-dashboards/frontend/web-vitals/)). SDK 11's stream mode collects LCP, CLS and INP per soft navigation and per cache restore by default ([integration](https://docs.sentry.io/platforms/javascript/configuration/integrations/webvitals/)). Every span in a trace is billed ([Sentry help](https://www.sentry.help/en/articles/13964867-what-is-the-relation-between-performance-units-and-spans)), and a page load trace carries resource and request spans besides the vitals; every plan includes 5M spans a month ([pricing](https://docs.sentry.io/pricing/)). `tracesSampleRate` samples vitals along with traces.

### Google Analytics 4 with the web-vitals library

GA4 has no vitals report. You send each metric as an event with a unique `metric_id` using `web-vitals`, and GA4 "does not support reporting metric distributions in any of its built-in reports" ([README](https://github.com/GoogleChrome/web-vitals#send-the-results-to-google-analytics)), so a 75th percentile needs the Data API or BigQuery ([web.dev](https://web.dev/articles/vitals-ga4)). Standard properties export 1M events a day; streaming export costs $0.05 per GB, about 600,000 events ([Google](https://support.google.com/analytics/answer/9358801)). With up to five vitals events per page view, busy sites hit that cap fast.

### invisit

invisit, the tool we build, collects LCP, INP, CLS, FCP and TTFB with its own module rather than `web-vitals`. Vitals are on by default, can be switched off per site and are [never billed](https://invisit.dev/docs/guides/billing.md). The module is a separate file of about 1.1 KB gzipped, requested only after the site's settings confirm vitals are on (a dynamic import in the npm package). It uses buffered observers, Google's thresholds, and Chrome's rules for INP (the slowest interaction, skipping one per 50) and CLS (the worst burst of shifts, at most 5 seconds long).

invisit sends one set per full page load, the first time the page is hidden, filed under the URL showing at that moment. In a single-page app, INP and CLS cover every route the visitor saw and the set lands on the route where they left. It does not split client-side routes or cache restores, and sends no element selectors or sub-parts; for those, use the `web-vitals` attribution build, Cloudflare, Sentry or Speed Insights Plus.

The Web Vitals screen shows each metric's 75th percentile on the device type that hurts the score most, the share of good, needs improvement and poor loads, splits by page, device, country and browser, and a 0 to 100 score weighted LCP 30, INP 30, CLS 25, FCP 10 and TTFB 5. The [REST API](https://invisit.dev/docs/api/rest.md) and the [MCP server](https://invisit.dev/docs/api/mcp.md)'s `get_web_vitals` tool return the 50th, 75th, 90th, 95th or 99th percentile.

## Before you trust your real-user numbers

Run PageSpeed Insights on your three busiest landing pages and note whether each shows page data, origin data or none. Then collect your own for a week and check:

- [ ] Values go out when the page is hidden, and you keep the last value per metric `id`.
- [ ] INP uses `reportAllChanges: true` until a `web-vitals` release after 6.2.3 ships pull request #809.
- [ ] You read the 75th percentile per device type, never the mean.
- [ ] Cache restores and prerenders are measured as Chrome measures them.
- [ ] In a single-page app, you chose per-route numbers or numbers that match CrUX today.
- [ ] Beacons go to your own origin.

> [!TIP]
> If your numbers still disagree with CrUX after filtering to Chrome, one device type and 28 days, look at iframes, background tabs and single-page app routes first.
