Skip to main content

10 - Measuring and Monitoring Core Web Vitals in Production

Measuring Core Web Vitals in the lab is not enough: field data from Chrome User Experience Report (CrUX) often diverges 30-50% from synthetic data. Guide to real-user monitoring with web-vitals.js, BigQuery and P75.

What do you think about this article?

10 - Measuring and Monitoring Core Web Vitals in Production
Core Web VitalsPhotos:Lukas BlazekPexels

At a Glance

Measuring Core Web Vitals in the lab is not enough: field data from Chrome User Experience Report (CrUX) often diverges 30-50% from synthetic data. Guide to real-user monitoring with web-vitals.js, BigQuery and P75.

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 milliseconds
  • interaction_to_next_paint.good/needs_improvement/poor — INP in milliseconds
  • cumulative_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).

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects