I ran a Lighthouse audit on matthewswong.com and scored 98. Then I checked the Chrome User Experience Report (CrUX) field data and found my LCP was 3.1 seconds for real users on mobile. The gap shocked me. That experience is exactly why Real User Monitoring (RUM) isn't optional — it's the only way to know what your users actually experience. Google's ranking algorithm uses CrUX field data, not your Lighthouse score. If you're optimizing for the lab but not the field, you're playing a different game than Google is.
Lighthouse runs in a controlled Chrome instance on a throttled CPU and simulated 4G connection. It's a synthetic test — useful for catching regressions in CI, but fundamentally disconnected from the diversity of real-world conditions. RUM (Real User Monitoring) collects performance data from actual page loads on real devices and networks. The Web Vitals JavaScript library, maintained by Google, can be embedded in any site to report LCP, INP, CLS, FCP, and TTFB for each real session. The data gets aggregated in CrUX with a 28-day rolling window — a score today reflects the past four weeks of actual user visits.
CrUX (Chrome User Experience Report) is the public dataset that powers Google's Core Web Vitals assessment in Search Console and PageSpeed Insights. It's built from opt-in Chrome users across the globe. For your site to have CrUX data, it needs sufficient traffic (Google doesn't publish exact thresholds, but roughly 1,000+ monthly page loads with Chrome users who have usage statistics sharing enabled). The data is published as a BigQuery dataset, exposed through the CrUX API, and visible in PageSpeed Insights. The percentile that matters for Google ranking is p75 — the 75th percentile of your users' experiences.
Google's open-source web-vitals library (github.com/GoogleChrome/web-vitals) is the reference implementation for measuring CWV in the field. Installing it takes two minutes: `npm install web-vitals`. Then import the metric reporters (onLCP, onINP, onCLS, onFCP, onTTFB) and send the data to your analytics endpoint. The library correctly handles the nuances of each metric — for example, INP is only reported on page hide or when the user has interacted, not continuously. For a portfolio site like mine, I send the data to Vercel Analytics, which aggregates it and surfaces the p75 values.
Lab Data (Lighthouse) Field Data (CrUX / RUM)
────────────────────── ──────────────────────────────
Controlled Chrome instance Real user browsers (Chrome opt-in)
Simulated 4G + mid-tier CPU Actual device + network diversity
Single test run 28-day rolling window (p75)
Build-time CI check Google Search ranking signal
web-vitals JS library flow:
User visits page
│
▼
LCP measured (largest element renders)
INP measured (worst interaction recorded)
CLS measured (layout shift accumulated)
│
▼
onLCP / onINP / onCLS callbacks fire
│
▼
Send to: Vercel Analytics / GA4 / custom endpoint
│
▼
Aggregate → p75 per metric per URLFrom my experience setting up RUM on matthewswong.com: the biggest gap between lab and field LCP came from users on slow connections who triggered a fallback font — the font hadn't loaded by the time the LCP element painted, so the browser had to repaint once the font arrived, adding 800ms to field LCP. Lighthouse never caught this because it tests with a simulated connection that front-loads the font. Fix: preload the font with a link rel=preload tag.
You don't need Datadog or New Relic to get started with RUM. For a Next.js app, Vercel Analytics is free up to 2,500 events/month and integrates with one package install. For self-hosted or custom setups, you can send web-vitals data to any analytics endpoint — Google Analytics 4 supports custom events. The pattern is: import the web-vitals reporters in your root layout, call them with a callback that sends to your analytics system, and set up a dashboard to visualize the p75 values over time.
I use a thin wrapper around the web-vitals library that sends metrics to a Vercel Analytics custom event. The implementation is under 20 lines. The key fields I capture: metric name, metric value, metric rating (good/needs-improvement/poor per Google's thresholds), navigation type (navigate/reload/back-forward), and a debug target (the element selector for LCP, or the interaction type for INP). That last field — debug target — is invaluable: it tells you not just that LCP is slow, but which specific element is the culprit.
// app/layout.tsx — RUM setup for Next.js
import { onCLS, onFCP, onINP, onLCP, onTTFB } from 'web-vitals'
function sendToAnalytics({ name, value, rating, navigationType, id }: {
name: string; value: number; rating: string;
navigationType: string; id: string
}) {
// Vercel Analytics (free tier)
if (typeof window !== 'undefined' && window.va) {
window.va('event', { name: `cwv_${name.toLowerCase()}`, value, rating })
}
// Or send to your own endpoint
fetch('/api/metrics', {
method: 'POST',
body: JSON.stringify({ name, value, rating, navigationType, id }),
keepalive: true, // survives page unload
})
}
// Call in a useEffect in your root layout client component
export function WebVitalsReporter() {
useEffect(() => {
onLCP(sendToAnalytics)
onINP(sendToAnalytics)
onCLS(sendToAnalytics)
onFCP(sendToAnalytics)
onTTFB(sendToAnalytics)
}, [])
return null
}
// CrUX API query (weekly monitoring script)
const response = await fetch(
`https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=${API_KEY}`,
{
method: 'POST',
body: JSON.stringify({
url: 'https://matthewswong.com/',
metrics: ['largest_contentful_paint', 'interaction_to_next_paint', 'cumulative_layout_shift'],
}),
}
)
const data = await response.json()
// Check: data.record.metrics.largest_contentful_paint.percentiles.p75When I analyze RUM data for a production site, I look at three dimensions: metric value distribution (not just averages — the p75 and p95 matter), segmentation by device type and connection type, and correlation with bounce rate. A site can have excellent p50 LCP (2.0s) but terrible p95 (8.0s) — meaning most users are fine but a tail of users on slow connections have an awful experience. Segmenting by connection type often reveals that your mobile 3G users are in a different reality than your desktop fiber users.
PageSpeed Insights shows both lab data (Lighthouse) and field data (CrUX) on the same page. I've seen developers report their 'Google score' and show the lab number while the field data is red. The field data is what Google uses for ranking. If your field data shows a poor LCP but your lab score is 95, your ranking is based on the poor LCP. Always scroll to the field data section first in PageSpeed Insights.
Google's research shows a 0.1s improvement in LCP correlates with a 5-10% improvement in conversion rates for e-commerce. For content sites, a 100ms improvement in FCP correlates with a 7% reduction in bounce rate. These aren't guaranteed results — they're population-level correlations — but they give RUM improvements a business case. When I present performance work to stakeholders, I frame RUM improvements in terms of user sessions that fall into the 'poor' bucket (LCP >4s) and estimate the conversion impact of moving those sessions to 'good'.
The goal is to know about field data regressions before they affect ranking. Set up a weekly job (GitHub Actions cron or a simple serverless function) that queries the CrUX API for your URLs and compares p75 values against your thresholds. Alert if LCP p75 exceeds 2.5s, INP p75 exceeds 200ms, or CLS p75 exceeds 0.1. For high-traffic sites, query daily. The CrUX API is free and requires only a Google API key. Pair this with Lighthouse CI in your PR pipeline (which catches regressions before deploy) and you have a full coverage system: lab catches before-deploy, RUM catches after-deploy in the real world.