invisit
All posts

By Miraç Berk14 min read

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.

A soft 3D stopwatch beside three gauge dials marked good, needs improvement and poor, lit by violet dithered light with soft shadows.

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). Our script size post 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). 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). 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 and, for FCP and TTFB, the PageSpeed Insights docs, 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). Thresholds are the same for phones and desktops, but Google recommends reading the 75th percentile for each separately (web.dev), 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):

CrUX also sees what JavaScript cannot: iframe content counts toward LCP and CLS, and background-tab loads are left out (web.dev). 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).

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). Search Console reads the same dataset and has the same gaps. The usual causes:

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.

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.

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 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.

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.

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:

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). 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.

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). 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). Three catches:

Page views have the same problem; see counting page views in single-page apps.

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). 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).

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). 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). 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).

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). 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.

Cloudflare Web Analytics

Cloudflare Web Analytics is free (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). The 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). 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, our comparison).

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). SDK 11’s stream mode collects LCP, CLS and INP per soft navigation and per cache restore by default (integration). Every span in a trace is billed (Sentry help), and a page load trace carries resource and request spans besides the vitals; every plan includes 5M spans a month (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), so a 75th percentile needs the Data API or BigQuery (web.dev). Standard properties export 1M events a day; streaming export costs $0.05 per GB, about 600,000 events (Google). 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. 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 and the MCP server’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: