Why CrUX is not enough
Google's Chrome UX Report is the field data that ranks you, and it has three limits. It lags by 28 days. It is Chrome only. And for a small business site it is often too thin: page groups below the traffic threshold show no data at all, so you know the origin's grade and nothing about which pages.
Real-user monitoring (RUM) you run yourself fills the gap: every browser, every page, today. The question is how to collect it so that the numbers are trustworthy, and the first obstacle is that a large share of visitors run ad blockers that silently drop requests to known analytics domains.
The collection script
The web-vitals library, attribution build, on every page, as a small client component:
// lib/vitals.ts
import { onCLS, onINP, onLCP, onTTFB, onFCP } from 'web-vitals/attribution'
type Payload = Record<string, unknown>
function send(payload: Payload) {
const body = JSON.stringify(payload)
if (navigator.sendBeacon) navigator.sendBeacon('/api/vitals', body)
else fetch('/api/vitals', { method: 'POST', body, keepalive: true })
}
export function initVitals(pageGroup: string) {
const base = {
page: location.pathname,
group: pageGroup,
nav: (performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined)?.type,
conn: (navigator as { connection?: { effectiveType?: string } }).connection?.effectiveType,
ua: navigator.userAgent.slice(0, 120),
}
const report = (m: { name: string; value: number; rating: string; id: string; attribution: Record<string, unknown> }) => {
const a = m.attribution
send({
...base,
metric: m.name,
value: Math.round(m.value * (m.name === 'CLS' ? 1000 : 1)) / (m.name === 'CLS' ? 1000 : 1),
rating: m.rating,
id: m.id,
// per-metric attribution, trimmed to what we aggregate on
target: (a.element ?? a.interactionTarget ?? a.largestShiftTarget) as string | undefined,
loadState: a.loadState,
inputDelay: a.inputDelay,
processing: a.processingDuration,
presentation: a.presentationDelay,
loadDelay: a.resourceLoadDelay,
loadDur: a.resourceLoadDuration,
renderDelay: a.elementRenderDelay,
ttfb: a.timeToFirstByte,
})
}
onLCP(report); onINP(report); onCLS(report); onTTFB(report); onFCP(report)
}sendBeacon survives page unload. keepalive on the fetch fallback does the same. The attribution fields are the ones that turn a bad number into a diagnosis.
The endpoint: first-party, boring
The reason to post to /api/vitals on your own domain rather than to a vendor: blockers block by hostname and known paths. A same-origin path with a dull name is not on any list. In Next.js:
// app/api/vitals/route.ts
export const runtime = 'edge'
export async function POST(req: Request) {
const data = await req.json().catch(() => null)
if (!data || typeof data.metric !== 'string') return new Response(null, { status: 400 })
const row = {
ts: Date.now(),
country: req.headers.get('x-vercel-ip-country') ?? req.headers.get('cf-ipcountry') ?? undefined,
...data,
}
// append to your store of choice: a Postgres table, ClickHouse, a Tinybird datasource,
// an analytics warehouse, or a log drain that lands in one of those.
await appendVital(row)
return new Response(null, { status: 204 })
}No cookies, no user identifier, no IP stored: aggregate performance data is not personal data and it is simpler to keep it that way. Rate-limit at the edge if you are worried about abuse; the volume is a few rows per page view.
What to store, and what to aggregate
Store the raw rows for 30 to 90 days. Aggregate nightly:
- p75 per metric per page group per device class (mobile versus desktop from the UA, or from viewport width if you send it). p75 is what Google uses; the mean hides the tail.
- Rating distribution (good / needs-improvement / poor share) per group.
- Top
targetelements for poor INP and poor CLS, per group, weekly. This is the list of what to fix. - LCP breakdown (TTFB, load delay, load duration, render delay) per group, so you know whether the fix is server, discovery, bytes or render.
- Navigation type share: how many page views were back-forward cache restores.
A small dashboard with a line per page group per metric, plus the target table, is enough. Keep it to one screen.
Page groups
Aggregate by template, not by URL. /blog/[slug] is one group; /websites-for/[vertical]/[sub] is another. Pass the group from the layout or page (the route pattern is known server-side) into initVitals. Per-URL data is too thin to trust on most sites and too noisy to act on.
Compared with the alternatives
Vercel Speed Insights (and Netlify's equivalent): the same web-vitals data, collected to Vercel's endpoint, with a ready dashboard by route. Excellent for zero effort. Its endpoint is on a Vercel domain and is blocked by some blockers, and you do not own the raw rows. Run it alongside your own for the dashboard; use your own for the attribution detail and the blocker-proof numbers.
Commercial RUM (SpeedCurve, DebugBear, Datadog, New Relic Browser): richer dashboards, session context, alerting, at a price and with the same blocker exposure unless they support a first-party proxy (several do; use it).
CrUX: the judge. Your RUM should roughly agree with CrUX where both have data. If your p75 LCP is 1.8 seconds and CrUX says 2.9, either your sampling is skewed (you are missing the slow devices that blockers are common on, which the first-party endpoint should fix) or your page groups are cut differently. Reconcile before trusting either.
What changes when you have it
You stop guessing. "The site is slow" becomes "service pages have a p75 INP of 340 milliseconds on mobile, 70 percent of poor interactions are the menu toggle, and input delay dominates during load." A regression from a new script shows up the next morning, not next month. And the before-and-after of every fix is a number you can show the client from their own visitors.
Where this sits
First-party RUM is fix zero in any Core Web Vitals engagement where field data is thin or where INP is the problem, because nothing else tells you which interaction. The endpoint is forty lines, the script is fifty, and it is on every site we build because the first question about performance is always "for whom", and this is the only tool that answers it.