Measuring and Monitoring Core Web Vitals in Production
Measuring Core Web Vitals in the lab with Lighthouse gives you a baseline — but real field data often diverges 30-50%. A site with LCP of 1.5s in the lab might have a P75 of 4s in production. This guide teaches you how to collect real field data with web-vitals.js, send it to BigQuery, and monitor performance regressions with automatic alerting.
The difference between lab and field data is radical: the lab simulates an average device on a controlled connection, while field data reflects real users on diverse devices (slow 4G smartphones, fast WiFi desktops, tablets with limited CPU). To understand how your site truly performs, you must measure in the field.
What You Will Learn
- Differences between lab data (Lighthouse) and field data (CrUX, RUM)
- Chrome User Experience Report (CrUX): accessing aggregated field data
- web-vitals.js: collecting INP, LCP, CLS from real users
- Backend receiving and storage in BigQuery
- SQL queries to calculate P75 and regression trends
- Dashboard and visualization of field metrics
- Automatic alerting on performance regressions
- PageSpeed Insights API for CrUX-linked insights
Lab vs Field Data: Why They Diverge 30-50%
Lighthouse simulates a predefined profile: Pixel 5 on throttled 4G network (4Mbps download, 3Mbps upload). Real users have completely different devices: variable processors, fluctuating connections (inconsistent WiFi), background apps consuming resources. This variability causes significant divergences.
What Lighthouse Measures (Lab)
Lighthouse is synthetic: it navigates your site in a controlled environment once, measuring metrics with:
- Simulated device: Pixel 5 (4x CPU throttle, fixed memory)
- Simulated network: slow 4G (4 Mbps down, 3 Mbps up, 150ms latency)
- Cache: clean for the test (cold load, not representative)
- Variability: low (same scenario every time)
Perfect for local debugging and development, but not for business decisions.
What CrUX Measures (Field)
CrUX (Chrome User Experience Report) collects real data from millions of Chrome users who visit your site:
- Varied devices: smartphones, tablets, desktops with different processors and RAM
- Real connections: WiFi, 4G, 5G — variable speed and latency
- Real behavior: heavy background load, low battery, thermal throttling
- Large sample: millions of sessions → realistic distributions
- Percentiles: P75, P90, P95 — what the majority of users perceive
CrUX is Google's official source of truth for SEO ranking and core web vitals.
Chrome User Experience Report (CrUX)
Google publishes CrUX publicly on BigQuery, with billions of metric records. If your site receives at least 100k sessions/month on Chrome, it will appear in CrUX (with a 1-2 week delay).
Accessing CrUX Data on BigQuery
-- Query BigQuery CrUX: P75 for LCP, INP, CLS
SELECT
origin,
yyyymm,
percentile_exc(largest_contentful_paint.good, 0.75) as lcp_p75_good,
percentile_exc(interaction_to_next_paint.good, 0.75) as inp_p75_good,
percentile_exc(cumulative_layout_shift.good, 0.75) as cls_p75_good,
COUNT(DISTINCT origin) as num_origins
FROM `chrome-ux-report.all.202406`
WHERE origin = 'https://example.com/'
GROUP BY origin, yyyymm
ORDER BY yyyymm DESC;
Dataset: chrome-ux-report.all.YYYYMM (e.g., 202406 = June 2024).
Main metrics:
largest_contentful_paint.good/needs_improvement/poor— LCP in millisecondsinteraction_to_next_paint.good/needs_improvement/poor— INP in millisecondscumulative_layout_shift.good/needs_improvement/poor— CLS dimensionless (0-1)
Real User Monitoring with web-vitals.js
web-vitals is an official Google library of 1.5KB that measures LCP, INP, CLS,
FCP, TTFB in real-user. Send the data to your backend for collection and custom analysis.
Installation and Basic Setup
npm install web-vitals
// src/monitoring/vitals.ts
import { getCLS, getFCP, getFID, getLCP, getTTFB, getINP } from 'web-vitals';
export interface WebVital {
name: string;
value: number;
rating: 'good' | 'needs-improvement' | 'poor';
delta: number;
id: string;
navigationType: string;
}
// Collect metrics
export function initWebVitals() {
getCLS(onMetric);
getFCP(onMetric);
getLCP(onMetric);
getINP(onMetric);
getTTFB(onMetric);
}
function onMetric(metric: any) {
const payload: WebVital = {
name: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType
};
sendVitalToBackend(payload);
}
Sending Metrics to Backend
// Endpoint backend: POST /api/v1/telemetry/web-vitals
async function sendVitalToBackend(vital: WebVital) {
const payload = {
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
vital: vital,
sessionId: getSessionId(), // local UUID
deviceMemory: navigator.deviceMemory || null,
hardwareConcurrency: navigator.hardwareConcurrency || null,
effectiveType: (navigator as any).connection?.effectiveType || 'unknown'
};
try {
// Batch send: every 5 metrics or 30s
vitalQueue.push(payload);
if (vitalQueue.length >= 5 || timeSinceLastBatch > 30000) {
await fetch('/api/v1/telemetry/web-vitals', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vitals: vitalQueue, timestamp: new Date().toISOString() }),
keepalive: true // Continue sending even after unload
});
vitalQueue = [];
}
} catch (e) {
// Silent fail: metrics non-critical
console.error('Vital telemetry failed:', e);
}
}
Collection and Analysis in BigQuery
The backend receives vitals and writes them to PostgreSQL or BigQuery. BigQuery is preferable for analytics queries at massive scale (billions of rows).
BigQuery Table Schema
-- BigQuery: telemetry.web_vitals
CREATE TABLE `project-id.telemetry.web_vitals` (
timestamp TIMESTAMP,
session_id STRING,
url STRING,
vital_name STRING, -- LCP, INP, CLS, FCP, TTFB
vital_value FLOAT64, -- milliseconds
vital_rating STRING, -- good / needs_improvement / poor
device_memory INT32,
hardware_concurrency INT32,
effective_type STRING, -- 4g, 3g, slow-2g
user_agent STRING,
country STRING,
os STRING,
browser STRING
)
PARTITION BY DATE(timestamp)
CLUSTER BY vital_name, country;
SQL Queries for P75 and Percentiles
-- P75 for LCP per day and country
SELECT
DATE(timestamp) as date,
country,
vital_name,
APPROX_QUANTILES(vital_value, 100)[OFFSET(75)] as p75_ms,
APPROX_QUANTILES(vital_value, 100)[OFFSET(90)] as p90_ms,
APPROX_QUANTILES(vital_value, 100)[OFFSET(95)] as p95_ms,
COUNT(*) as samples
FROM `project-id.telemetry.web_vitals`
WHERE vital_name IN ('LCP', 'INP', 'CLS')
AND timestamp > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
GROUP BY date, country, vital_name
ORDER BY date DESC, country, vital_name;
Trend and Regression Detection
-- Regression detection: P75 LCP today vs 7 days ago
WITH daily_p75 AS (
SELECT
DATE(timestamp) as date,
vital_name,
APPROX_QUANTILES(vital_value, 100)[OFFSET(75)] as p75_ms
FROM `project-id.telemetry.web_vitals`
WHERE vital_name = 'LCP'
AND timestamp > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 14 DAY)
GROUP BY date, vital_name
)
SELECT
today.date,
today.p75_ms as today_p75,
week_ago.p75_ms as week_ago_p75,
ROUND(((today.p75_ms - week_ago.p75_ms) / week_ago.p75_ms * 100), 2) as pct_change,
CASE
WHEN ((today.p75_ms - week_ago.p75_ms) / week_ago.p75_ms) > 0.1 THEN 'REGRESSION'
ELSE 'OK'
END as status
FROM daily_p75 today
LEFT JOIN daily_p75 week_ago
ON today.vital_name = week_ago.vital_name
AND week_ago.date = DATE_SUB(today.date, INTERVAL 7 DAY)
WHERE today.date = CURRENT_DATE();
Dashboard and Visualization
Create a dashboard (Grafana, Looker Studio, or custom React/Angular) that visualizes:
- Gauge charts: P75 LCP, INP, CLS vs target (green/yellow/red)
- Trend charts: daily trend of percentiles
- Breakdown by device: smartphone vs desktop vs tablet
- Breakdown by network: 4G vs WiFi vs 3G
- Heatmap: P75 per URL + country
- Alert status: active regressions
Looker Studio integrates directly with BigQuery and requires no backend:
-- Looker Studio: connect directly to the dataset
SELECT
TIMESTAMP_TRUNC(timestamp, DAY) as date,
vital_name,
APPROX_QUANTILES(vital_value, 100)[OFFSET(75)] as p75,
COUNT(*) as sample_size
FROM `project-id.telemetry.web_vitals`
GROUP BY date, vital_name
ORDER BY date DESC;
Alerting on Performance Regressions
Configure automatic alerts that notify the team when P75 regresses beyond a threshold (e.g., 15% in 24h).
Automated Monitoring Script (Node.js)
// monitoring/alert-checker.ts
import { BigQuery } from '@google-cloud/bigquery';
import nodemailer from 'nodemailer';
const bigquery = new BigQuery();
const mailer = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: 587,
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
});
async function checkRegressions() {
const query = `
WITH daily_p75 AS (
SELECT
DATE(timestamp) as date,
vital_name,
APPROX_QUANTILES(vital_value, 100)[OFFSET(75)] as p75_ms
FROM \`project-id.telemetry.web_vitals\`
WHERE timestamp > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 8 DAY)
GROUP BY date, vital_name
)
SELECT
today.date,
today.vital_name,
today.p75_ms,
week_ago.p75_ms,
ROUND(((today.p75_ms - week_ago.p75_ms) / week_ago.p75_ms * 100), 2) as pct_change
FROM daily_p75 today
LEFT JOIN daily_p75 week_ago
ON today.vital_name = week_ago.vital_name
AND week_ago.date = DATE_SUB(today.date, INTERVAL 7 DAY)
WHERE today.date = CURRENT_DATE()
AND ((today.p75_ms - week_ago.p75_ms) / week_ago.p75_ms) > 0.15
`;
const [rows] = await bigquery.query({ query });
for (const row of rows) {
if (row.pct_change > 15) {
await mailer.sendMail({
from: 'alerts@example.com',
to: process.env.ALERT_EMAIL,
subject: `PERF ALERT: ${row.vital_name} regressed ${row.pct_change}%`,
html: `
Performance Regression Detected
${row.vital_name}: ${row.p75_ms}ms (was ${row.week_ago_p75_ms}ms)
Change: ${row.pct_change}% in 7 days
View Dashboard
`
});
}
}
}
// Scheduled every hour via cron
setInterval(checkRegressions, 3600000);
PageSpeed Insights API
PageSpeed Insights combines lab data (Lighthouse) with official CrUX field data. You can query the API to monitor both lab and field automatically.
// Fetch PageSpeed Insights API
async function fetchPageSpeedMetrics(url: string) {
const apiKey = process.env.PAGESPEED_API_KEY;
const response = await fetch(
`https://www.googleapis.com/pagespeedonline/v5/runPagespeed?` +
`url=${encodeURIComponent(url)}&key=${apiKey}`
);
const data = await response.json();
// Lab data (Lighthouse)
const labMetrics = data.lighthouseResult.audits;
const labLcp = labMetrics['largest-contentful-paint'].numericValue;
const labInp = labMetrics['interaction-to-next-paint'].numericValue;
const labCls = labMetrics['cumulative-layout-shift'].numericValue;
// Field data (CrUX) — if available
const fieldData = data.loadingExperience?.metrics || {};
const fieldLcp = fieldData.LARGEST_CONTENTFUL_PAINT_MS?.percentile;
const fieldInp = fieldData.INTERACTION_TO_NEXT_PAINT_MS?.percentile;
const fieldCls = fieldData.CUMULATIVE_LAYOUT_SHIFT?.percentile;
console.log('Lab LCP:', labLcp, 'ms | Field LCP:', fieldLcp, 'ms');
console.log('Lab INP:', labInp, 'ms | Field INP:', fieldInp, 'ms');
console.log('Lab CLS:', labCls, '| Field CLS:', fieldCls);
return { lab: { lcp: labLcp, inp: labInp, cls: labCls }, field: { lcp: fieldLcp, inp: fieldInp, cls: fieldCls } };
}
Frequently Asked Questions (FAQ)
Q: What is the minimum data volume for CrUX?
A: Google requires at least 100k unique sessions/month from Chrome users on a URL. Smaller sites appear in BigQuery but with less geographic and device detail.
Q: How does INP differ from FID?
A: FID measures only the first input; INP (Interaction to Next Paint) measures all inputs on the page and takes the 75th percentile. INP is the new official metric since 2024.
Q: How much overhead does web-vitals.js have?
A: ~1.5KB minified, negligible performance impact. PerformanceObserver is native and does not block rendering.
Q: Can I use Ollama to analyze performance data?
A: Yes, you can export BigQuery queries and use local Ollama (`llama3.1:8b`) to interpret trends and suggest optimizations, keeping data on-prem.
Q: How do I recognize false positive regressions?
A: Always compare against weekly trend (not just daily) and evaluate % change (e.g., >15%) rather than absolute value. Explain anomalies (deploy, traffic spike).

Commenti
Caricamento commenti...
Sign in to comment