Measuring Web Vitals Programmatically: A Complete Guide

Table of Contents
Big thanks to our contributors those make our blogs possible.

Our growing community of contributors bring their unique insights from around the world to power our blog. 

Introduction

Core Web Vitals—Largest Contentful Paint (LCP), First Input Delay (FID), and Cumulative Layout Shift (CLS)—are Google’s standardized measures of real‑user experience. Unlike synthetic lab tests, which run in a fixed environment, Real‑User Monitoring (RUM) captures diverse devices, networks, and behaviors in production. Instrumenting Web Vitals programmatically empowers you to:

  • Detect regressions immediately after deployments
  • Segment metrics by device, geography, A/B variant, and more
  • Correlate performance with business outcomes (conversions, retention)
  • Prioritize optimizations that impact real users

This guide dives deep into:

  1. Fundamentals of LCP, FID, and CLS
  2. Integration with the official Web Vitals library
  3. Custom instrumentation via PerformanceObserver
  4. Handling Single‑Page Apps and route changes
  5. Data pipeline design: sampling, sending, storing
  6. Dashboarding and alerting best practices
  7. Continuous improvement workflow
  8. Pitfalls and advanced tips

1. Core Web Vitals: What and Why

MetricDefinitionGood Threshold (75th pct)What It Reflects
LCPTime until the largest above‑the‑fold image or text block renders≤ 2.5 sHow quickly main content loads
FIDDelay between first user interaction and event handler start≤ 100 msResponsiveness to user input
CLSSum of all unexpected layout shift scores≤ 0.10Visual stability during page lifetime

Why RUM?
Lab tools simulate a single device. In production, 50 % of users may be on slow 3G phones in emerging markets—only RUM surfaces their pain points.

2. Instrumentation with the Web Vitals Library

Google’s web‑vitals package encapsulates best practices: polyfills, unique IDs, buffering, and CLS delta tracking.

2.1 Install & Bundle

bashCopyEditnpm install web-vitals

Include in your build (ESM, CommonJS, or UMD):

htmlCopyEdit<script src="https://unpkg.com/web-vitals/dist/web-vitals.iife.js"></script>

2.2 Core Usage Example

jsCopyEditimport { getLCP, getFID, getCLS } from 'web-vitals';

function reportMetric(metric) {
  const payload = {
    name: metric.name,                // 'LCP', 'FID', or 'CLS'
    value: metric.name === 'CLS'
             ? metric.value.toFixed(3)
             : Math.round(metric.value), // round timings
    delta: metric.delta?.toFixed(2),
    id: metric.id,                    // unique per metric instance
    url: location.pathname,
    connection: navigator.connection?.effectiveType,
    deviceMemory: navigator.deviceMemory,
    timestamp: metric.startTime || Date.now(),
  };

  const body = JSON.stringify(payload);
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/api/vitals', body);
  } else {
    fetch('/api/vitals', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body,
      keepalive: true
    });
  }
}

getLCP(reportMetric);
getFID(reportMetric);
getCLS(reportMetric);

Notes

  • keepalive: true ensures the fetch can finish even if the page unloads.
  • CLS values are small decimals (e.g., 0.05), so you may multiply by 1,000 when storing for integer-based DBs.

3. Raw Instrumentation with PerformanceObserver

For full control or environments without the library, use PerformanceObserver.

3.1 Observe LCP

jsCopyEditlet lcpCandidate = null;
const lcpObserver = new PerformanceObserver((entries) => {
  entries.getEntries().forEach(entry => {
    lcpCandidate = entry; // keep the last one
  });
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });

function sendLCP() {
  if (!lcpCandidate) return;
  sendMetric({
    name: 'LCP',
    value: lcpCandidate.startTime,
    id: lcpCandidate.element?.id || 'unknown'
  });
}
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    sendLCP();
  }
});

3.2 Observe FID

jsCopyEditconst fidObserver = new PerformanceObserver((entries) => {
  const entry = entries.getEntries()[0];
  if (!entry) return;
  sendMetric({
    name: 'FID',
    value: entry.processingStart - entry.startTime,
    id: entry.name
  });
});
fidObserver.observe({ type: 'first-input', buffered: true });

3.3 Observe CLS

jsCopyEditlet clsTotal = 0;
const clsObserver = new PerformanceObserver((entries) => {
  for (const entry of entries.getEntries()) {
    if (!entry.hadRecentInput) {
      clsTotal += entry.value;
    }
  }
});
clsObserver.observe({ type: 'layout-shift', buffered: true });

function sendCLS() {
  sendMetric({ name: 'CLS', value: clsTotal.toFixed(3), id: 'CLS' });
}
window.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') sendCLS();
});

4. Handling Single‑Page Applications

In SPAs, route changes don’t trigger page unloads. You must:

  1. Reset metrics on navigation
  2. Re‑observe after rendering the new view

Example (React Router):

jsCopyEditimport { useEffect } from 'react';
import { getLCP, getFID, getCLS } from 'web-vitals';
import { useLocation } from 'react-router-dom';

export default function RouteMetrics() {
  const location = useLocation();

  useEffect(() => {
    // Clear previous observers if needed...
    const disconnectAll = () => {
      PerformanceObserver.supportedEntryTypes.forEach(type => {
        const obs = new PerformanceObserver(() => {});
        obs.observe({ type, buffered: false });
        obs.disconnect();
      });
    };
    disconnectAll();
    
    getLCP(reportMetric);
    getFID(reportMetric);
    getCLS(reportMetric);
  }, [location.pathname]);

  return null;
}

5. Designing Your Analytics Pipeline

5.1 Front‑End Sampling

To limit data volume:

jsCopyEditconst SAMPLE_RATE = 0.05; // 5%
if (Math.random() > SAMPLE_RATE) return;
getLCP(reportMetric);
getFID(reportMetric);
getCLS(reportMetric);

5.2 Server‑Side Ingestion

  • Endpoint: POST /api/vitals
  • Validate JSON, enrich with geolocation, user agent data.
  • Insert into a columnar or time‑series store (BigQuery, ClickHouse, InfluxDB).

5.3 Aggregation Queries

Compute the 75th percentile for each metric per segment:

sqlCopyEdit-- BigQuery example
SELECT
  pageURL,
  APPROX_QUANTILES(metricValue, 100)[OFFSET(75)] AS p75_value
FROM
  `project.dataset.web_vitals`
WHERE
  metricName = 'LCP'
  AND DATE(timestamp) BETWEEN '2025-07-01' AND '2025-07-31'
GROUP BY pageURL;

6. Dashboarding & Alerting

  • Use Grafana, Looker, or Data Studio to visualize trends.
  • Key views:
    • Time series of p75 Web Vitals per page
    • Breakdowns by device type, connection (2g/3g/4g/wifi), geography
  • Alerts: Trigger when p75 exceeds thresholds for > 5 minutes.
  • Automated reports: Daily summaries emailed to dev and product teams.

7. Continuous Improvement Workflow

  1. Baseline Collection: 1–2 weeks of data post‑instrumentation
  2. Identify Bottlenecks: Top‑traffic pages with poor metrics
  3. Implement Fixes:
    • LCP: Preload key images, optimize server response, resize images
    • FID: Break up long JS tasks, use web workers, defer non‑critical scripts
    • CLS: Reserve image dimensions, avoid inserting ads above content
  4. A/B Testing: Feature flags to compare performance before full rollout
  5. Re‑Measure: Confirm improvements in RUM data
  6. Document: Track changes, date of deployment, and performance delta

8. Pitfalls & Advanced Tips

PitfallSolution
Missing buffered: true on observersAlways set { buffered: true } to capture early entries
Not handling SPA navigationRe‑initialize observers on route changes
Sending metrics after unload without keepaliveUse navigator.sendBeacon or fetch with keepalive: true
Under‑sampling key pagesApply higher sample rate on critical user journeys
Including PII in payloadStrip any identifiers; use anonymized session IDs
Overloading analytics endpointThrottle calls or batch metrics client‑side before sending
  • Preconnect to your analytics domain to reduce upload latency: htmlCopyEdit<link rel="preconnect" href="https://example.com">
  • Session Correlation: Add a hashed session ID (no PII) to link metrics with logs or errors.

Conclusion

By instrumenting LCP, FID, and CLS with the web‑vitals library or custom PerformanceObserver code, and building a robust analytics pipeline—complete with sampling, enrichment, aggregation, and alerting—you transform performance monitoring from theory to real‑user insights. This data‑driven approach ensures you focus optimizations where they matter most, improving both Core Web Vitals scores and actual user satisfaction over time. Continuous measurement, paired with an iterative improvement workflow, is your path to a consistently fast, responsive, and stable web experience.

Let's connect on TikTok

Join our newsletter to stay updated

Sydney Based Software Solutions Professional who is crafting exceptional systems and applications to solve a diverse range of problems for the past 10 years.

Share the Post

Related Posts