perf hacks
Core Web VitalsMeasuring

Measuring & Monitoring Core Web Vitals

Tools and techniques for measuring, monitoring, and improving Core Web Vitals

Effective performance optimization requires accurate measurement. This guide covers the tools and techniques for measuring Core Web Vitals in both development and production.

Lab vs Field Data

Understanding the Difference

TypeSourceProsCons
Lab DataLighthouse, DevToolsReproducible, detailed, immediateDoesn't reflect real users
Field DataCrUX, RUMReal user experience, diverse conditionsDelayed, less detailed

Google Search Console uses field data (CrUX) for ranking decisions. Lab data is for debugging, not for SEO assessment.

Setting Up web-vitals

web-vitals.ts
web-vitals-debug.ts
rum.ts

Basic Setup

npm install web-vitals
lib/web-vitals.ts
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals';
import type { Metric } from 'web-vitals';

function sendToAnalytics(metric: Metric) {
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    delta: metric.delta,
    id: metric.id,
    navigationType: metric.navigationType,
  });

  // Use sendBeacon for reliability
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/api/analytics', body);
  } else {
    fetch('/api/analytics', {
      body,
      method: 'POST',
      keepalive: true,
    });
  }
}

export function reportWebVitals() {
  onCLS(sendToAnalytics);
  onINP(sendToAnalytics);
  onLCP(sendToAnalytics);
  onFCP(sendToAnalytics);
  onTTFB(sendToAnalytics);
}

Integration with Next.js

app/layout.tsx
'use client';

import { useEffect } from 'react';
import { reportWebVitals } from '@/lib/web-vitals';

export default function RootLayout({ children }) {
  useEffect(() => {
    reportWebVitals();
  }, []);

  return (
    <html>
      <body>{children}</body>
    </html>
  );
}

With Attribution for Debugging

lib/web-vitals-debug.ts
import {
  onCLS,
  onINP,
  onLCP,
  type CLSMetricWithAttribution,
  type INPMetricWithAttribution,
  type LCPMetricWithAttribution,
} from 'web-vitals/attribution';

export function reportWithAttribution() {
  onLCP((metric: LCPMetricWithAttribution) => {
    console.log('LCP Debug:', {
      value: metric.value,
      element: metric.attribution.element,
      url: metric.attribution.url,
      timeToFirstByte: metric.attribution.timeToFirstByte,
      resourceLoadDelay: metric.attribution.resourceLoadDelay,
      resourceLoadDuration: metric.attribution.resourceLoadDuration,
      elementRenderDelay: metric.attribution.elementRenderDelay,
    });
  });

  onINP((metric: INPMetricWithAttribution) => {
    console.log('INP Debug:', {
      value: metric.value,
      interactionTarget: metric.attribution.interactionTarget,
      interactionType: metric.attribution.interactionType,
      inputDelay: metric.attribution.inputDelay,
      processingDuration: metric.attribution.processingDuration,
      presentationDelay: metric.attribution.presentationDelay,
    });
  });

  onCLS((metric: CLSMetricWithAttribution) => {
    console.log('CLS Debug:', {
      value: metric.value,
      largestShiftTarget: metric.attribution.largestShiftTarget,
      largestShiftValue: metric.attribution.largestShiftValue,
    });
  });
}

Chrome DevTools

Performance Panel

Open DevTools Press F12 or Cmd+Option+I

Go to Performance tab Select the Performance panel

Configure settings Enable CPU throttling (4x slowdown) and Network throttling (Fast 3G)

Record page load Click Record → Refresh page → Stop recording

Analyze results Look at Timings row for LCP, CLS markers

Lighthouse

# CLI usage
npx lighthouse https://example.com --view

# With specific categories
npx lighthouse https://example.com \
  --only-categories=performance \
  --output=json \
  --output-path=./report.json

Core Web Vitals Overlay

// Enable in development
// Add to your root layout or _app.tsx
if (process.env.NODE_ENV === 'development') {
  import('web-vitals').then(({ onCLS, onINP, onLCP }) => {
    const overlay = document.createElement('div');
    overlay.style.cssText = `
      position: fixed;
      bottom: 20px;
      right: 20px;
      background: #1a1a1a;
      color: white;
      padding: 12px;
      border-radius: 8px;
      font-family: monospace;
      font-size: 12px;
      z-index: 99999;
    `;
    document.body.appendChild(overlay);

    const metrics = { LCP: '-', INP: '-', CLS: '-' };

    function updateOverlay() {
      overlay.innerHTML = Object.entries(metrics)
        .map(([name, value]) => `${name}: ${value}`)
        .join('<br>');
    }

    onLCP((m) => { metrics.LCP = `${m.value.toFixed(0)}ms`; updateOverlay(); });
    onINP((m) => { metrics.INP = `${m.value.toFixed(0)}ms`; updateOverlay(); });
    onCLS((m) => { metrics.CLS = m.value.toFixed(3); updateOverlay(); });

    updateOverlay();
  });
}

Real User Monitoring (RUM)

Vercel Analytics

app/layout.tsx
import { Analytics } from '@vercel/analytics/react';
import { SpeedInsights } from '@vercel/speed-insights/next';

export default function Layout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Analytics />
        <SpeedInsights />
      </body>
    </html>
  );
}

Custom RUM Implementation

lib/rum.ts
interface PerformanceData {
  url: string;
  userAgent: string;
  connection?: string;
  deviceMemory?: number;
  metrics: {
    name: string;
    value: number;
    rating: string;
  }[];
}

class RUM {
  private buffer: PerformanceData['metrics'] = [];
  private sent = false;

  constructor() {
    // Send data when page is hidden
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') {
        this.flush();
      }
    });
  }

  addMetric(metric: { name: string; value: number; rating: string }) {
    this.buffer.push(metric);
  }

  private flush() {
    if (this.sent || this.buffer.length === 0) return;
    this.sent = true;

    const data: PerformanceData = {
      url: window.location.href,
      userAgent: navigator.userAgent,
      connection: (navigator as any).connection?.effectiveType,
      deviceMemory: (navigator as any).deviceMemory,
      metrics: this.buffer,
    };

    navigator.sendBeacon('/api/rum', JSON.stringify(data));
  }
}

export const rum = new RUM();

PageSpeed Insights API

scripts/check-performance.ts
const API_KEY = process.env.PAGESPEED_API_KEY;

interface PSIResult {
  lighthouseResult: {
    audits: {
      'largest-contentful-paint': { numericValue: number };
      'cumulative-layout-shift': { numericValue: number };
      'total-blocking-time': { numericValue: number };
    };
  };
  loadingExperience: {
    metrics: {
      LARGEST_CONTENTFUL_PAINT_MS: { percentile: number };
      CUMULATIVE_LAYOUT_SHIFT_SCORE: { percentile: number };
      INTERACTION_TO_NEXT_PAINT: { percentile: number };
    };
  };
}

async function checkPerformance(url: string): Promise<void> {
  const apiUrl = new URL('https://www.googleapis.com/pagespeedonline/v5/runPagespeed');
  apiUrl.searchParams.set('url', url);
  apiUrl.searchParams.set('key', API_KEY!);
  apiUrl.searchParams.set('strategy', 'mobile');
  apiUrl.searchParams.set('category', 'performance');

  const response = await fetch(apiUrl);
  const data: PSIResult = await response.json();

  console.log('Lab Data (Lighthouse):');
  console.log('  LCP:', data.lighthouseResult.audits['largest-contentful-paint'].numericValue);
  console.log('  CLS:', data.lighthouseResult.audits['cumulative-layout-shift'].numericValue);
  console.log('  TBT:', data.lighthouseResult.audits['total-blocking-time'].numericValue);

  if (data.loadingExperience?.metrics) {
    console.log('\nField Data (CrUX):');
    console.log('  LCP:', data.loadingExperience.metrics.LARGEST_CONTENTFUL_PAINT_MS.percentile);
    console.log('  CLS:', data.loadingExperience.metrics.CUMULATIVE_LAYOUT_SHIFT_SCORE.percentile);
    console.log('  INP:', data.loadingExperience.metrics.INTERACTION_TO_NEXT_PAINT.percentile);
  }
}

CI/CD Integration

Lighthouse CI

.github/workflows/lighthouse.yml
name: Lighthouse CI
on: [push]
jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
      - run: npm ci && npm run build

      - name: Run Lighthouse
        uses: treosh/lighthouse-ci-action@v10
        with:
          urls: |
            https://example.com/
            https://example.com/products
          budgetPath: ./lighthouse-budget.json
          uploadArtifacts: true
lighthouse-budget.json
[
  {
    "path": "/*",
    "timings": [
      { "metric": "largest-contentful-paint", "budget": 2500 },
      { "metric": "first-contentful-paint", "budget": 1800 },
      { "metric": "cumulative-layout-shift", "budget": 0.1 },
      { "metric": "total-blocking-time", "budget": 200 }
    ]
  }
]

Performance Budget Alerts

scripts/performance-check.ts
interface Budget {
  LCP: number;
  INP: number;
  CLS: number;
}

const BUDGET: Budget = {
  LCP: 2500,
  INP: 200,
  CLS: 0.1,
};

async function checkAgainstBudget(metrics: Record<string, number>) {
  const failures: string[] = [];

  if (metrics.LCP > BUDGET.LCP) {
    failures.push(`LCP ${metrics.LCP}ms exceeds budget of ${BUDGET.LCP}ms`);
  }
  if (metrics.INP > BUDGET.INP) {
    failures.push(`INP ${metrics.INP}ms exceeds budget of ${BUDGET.INP}ms`);
  }
  if (metrics.CLS > BUDGET.CLS) {
    failures.push(`CLS ${metrics.CLS} exceeds budget of ${BUDGET.CLS}`);
  }

  if (failures.length > 0) {
    console.error('Performance budget exceeded:');
    failures.forEach(f => console.error(`  - ${f}`));
    process.exit(1);
  }

  console.log('All metrics within budget!');
}

Monitoring Dashboard

Data Structure for Analytics

types/performance.ts
interface PerformanceEntry {
  timestamp: Date;
  url: string;
  metrics: {
    lcp: number;
    inp: number;
    cls: number;
    fcp: number;
    ttfb: number;
  };
  context: {
    deviceType: 'mobile' | 'tablet' | 'desktop';
    connectionType: string;
    country: string;
    browser: string;
  };
}

// Calculate p75 for each metric
function calculateP75(entries: PerformanceEntry[], metric: keyof PerformanceEntry['metrics']): number {
  const values = entries.map(e => e.metrics[metric]).sort((a, b) => a - b);
  const index = Math.floor(values.length * 0.75);
  return values[index];
}

Measurement Checklist

Development

Set up web-vitals package Install and configure the official web-vitals library.

Enable attribution build for debugging Use web-vitals/attribution for detailed breakdowns.

Use Chrome DevTools Performance panel Profile real interactions and page loads.

Test with CPU/Network throttling Simulate slower devices and connections.

Check Lighthouse scores regularly Run audits during development.

Production

Implement RUM (Vercel Analytics or custom) Collect real user metrics from production.

Set up alerting for regression Get notified when metrics degrade.

Monitor p75 values weekly The 75th percentile is what Google uses.

Track by page, device type, country Identify specific problem areas.

Compare lab vs field data Lab is for debugging; field is the truth.

CI/CD

Add Lighthouse CI to pipeline Automate performance testing on every PR.

Define performance budgets Set thresholds for each metric.

Block PRs that exceed budgets Prevent performance regressions from shipping.

Track trends over time Monitor for gradual degradation.

Alert on significant regressions Catch problems before users notice.

Tools Summary

ToolTypeBest For
Chrome DevToolsLabDebugging, profiling
LighthouseLabComprehensive audits
PageSpeed InsightsBothQuick checks with field data
Search ConsoleFieldSite-wide CWV status
web-vitalsFieldCustom RUM
Vercel AnalyticsFieldNext.js apps on Vercel
Lighthouse CILabCI/CD integration

On this page