Skip to main content

Web Vitals and Performance Metrics in JavaScript

Created: May 8, 2026 Larry Qu 8 min read

Web Vitals are Google’s metrics for measuring user experience. This article covers understanding, measuring, and optimizing these critical metrics.

Introduction

Web Vitals measure:

  • Loading performance (LCP)
  • Interactivity (FID, INP)
  • Visual stability (CLS)

Understanding Web Vitals helps you:

  • Improve user experience
  • Rank better in search results
  • Reduce bounce rates
  • Increase conversions

Core Web Vitals

Largest Contentful Paint (LCP)

// LCP: Time when largest content element is visible
// Target: < 2.5 seconds

// Measuring LCP
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('LCP:', entry.renderTime || entry.loadTime);
  }
});

observer.observe({ entryTypes: ['largest-contentful-paint'] });
// Practical example: Monitoring LCP
class LCPMonitor {
  constructor() {
    this.lcpValue = null;
  }

  start() {
    const observer = new PerformanceObserver((list) => {
      const entries = list.getEntries();
      const lastEntry = entries[entries.length - 1];
      this.lcpValue = lastEntry.renderTime || lastEntry.loadTime;
      
      console.log('LCP updated:', this.lcpValue);
      
      // Send to analytics
      this.reportMetric('LCP', this.lcpValue);
    });

    observer.observe({ entryTypes: ['largest-contentful-paint'] });
  }

  reportMetric(name, value) {
    // Send to analytics service
    fetch('/api/metrics', {
      method: 'POST',
      body: JSON.stringify({ name, value })
    });
  }

  getValue() {
    return this.lcpValue;
  }
}

// Usage
const lcpMonitor = new LCPMonitor();
lcpMonitor.start();

First Input Delay (FID)

// FID: Time from user input to browser response
// Target: < 100 milliseconds

// Measuring FID
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('FID:', entry.processingDuration);
  }
});

observer.observe({ entryTypes: ['first-input'] });
// Practical example: Monitoring FID
class FIDMonitor {
  constructor() {
    this.fidValue = null;
  }

  start() {
    const observer = new PerformanceObserver((list) => {
      const entries = list.getEntries();
      const firstEntry = entries[0];
      this.fidValue = firstEntry.processingDuration;
      
      console.log('FID:', this.fidValue);
      
      // Send to analytics
      this.reportMetric('FID', this.fidValue);
    });

    observer.observe({ entryTypes: ['first-input'] });
  }

  reportMetric(name, value) {
    fetch('/api/metrics', {
      method: 'POST',
      body: JSON.stringify({ name, value })
    });
  }

  getValue() {
    return this.fidValue;
  }
}

// Usage
const fidMonitor = new FIDMonitor();
fidMonitor.start();

Cumulative Layout Shift (CLS)

// CLS: Measure of visual stability
// Target: < 0.1

// Measuring CLS
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) {
      console.log('CLS:', entry.value);
    }
  }
});

observer.observe({ entryTypes: ['layout-shift'] });
// Practical example: Monitoring CLS
class CLSMonitor {
  constructor() {
    this.clsValue = 0;
  }

  start() {
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (!entry.hadRecentInput) {
          this.clsValue += entry.value;
          console.log('CLS updated:', this.clsValue);
          
          // Send to analytics
          this.reportMetric('CLS', this.clsValue);
        }
      }
    });

    observer.observe({ entryTypes: ['layout-shift'] });
  }

  reportMetric(name, value) {
    fetch('/api/metrics', {
      method: 'POST',
      body: JSON.stringify({ name, value })
    });
  }

  getValue() {
    return this.clsValue;
  }
}

// Usage
const clsMonitor = new CLSMonitor();
clsMonitor.start();

Additional Web Vitals

Interaction to Next Paint (INP)

// INP: Measures responsiveness to all interactions
// Target: < 200 milliseconds

// Measuring INP
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('INP:', entry.duration);
  }
});

observer.observe({ entryTypes: ['event'] });

Time to First Byte (TTFB)

// TTFB: Time from request to first byte received
// Target: < 600 milliseconds

// Measuring TTFB
const perfData = performance.getEntriesByType('navigation')[0];
const ttfb = perfData.responseStart - perfData.fetchStart;

console.log('TTFB:', ttfb);

Comprehensive Metrics Monitoring

Web Vitals Library

// Using web-vitals library
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';

function sendMetric(metric) {
  // Send to analytics
  fetch('/api/metrics', {
    method: 'POST',
    body: JSON.stringify(metric)
  });
}

getCLS(sendMetric);
getFID(sendMetric);
getFCP(sendMetric);
getLCP(sendMetric);
getTTFB(sendMetric);

Custom Metrics Dashboard

// Create comprehensive metrics dashboard
class MetricsDashboard {
  constructor() {
    this.metrics = {
      LCP: null,
      FID: null,
      CLS: 0,
      TTFB: null,
      FCP: null,
      DCL: null,
      Load: null
    };
  }

  start() {
    this.measureNavigationTiming();
    this.measureLCP();
    this.measureFID();
    this.measureCLS();
  }

  measureNavigationTiming() {
    window.addEventListener('load', () => {
      const perfData = performance.getEntriesByType('navigation')[0];
      
      this.metrics.TTFB = perfData.responseStart - perfData.fetchStart;
      this.metrics.FCP = performance.getEntriesByName('first-contentful-paint')[0]?.startTime;
      this.metrics.DCL = perfData.domContentLoadedEventEnd - perfData.fetchStart;
      this.metrics.Load = perfData.loadEventEnd - perfData.fetchStart;
      
      this.reportMetrics();
    });
  }

  measureLCP() {
    const observer = new PerformanceObserver((list) => {
      const entries = list.getEntries();
      const lastEntry = entries[entries.length - 1];
      this.metrics.LCP = lastEntry.renderTime || lastEntry.loadTime;
      this.reportMetrics();
    });

    observer.observe({ entryTypes: ['largest-contentful-paint'] });
  }

  measureFID() {
    const observer = new PerformanceObserver((list) => {
      const firstEntry = list.getEntries()[0];
      this.metrics.FID = firstEntry.processingDuration;
      this.reportMetrics();
    });

    observer.observe({ entryTypes: ['first-input'] });
  }

  measureCLS() {
    let clsValue = 0;
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (!entry.hadRecentInput) {
          clsValue += entry.value;
          this.metrics.CLS = clsValue;
          this.reportMetrics();
        }
      }
    });

    observer.observe({ entryTypes: ['layout-shift'] });
  }

  reportMetrics() {
    console.log('Current Metrics:', this.metrics);
    
    // Send to analytics
    fetch('/api/metrics', {
      method: 'POST',
      body: JSON.stringify(this.metrics)
    });
  }

  getMetrics() {
    return this.metrics;
  }
}

// Usage
const dashboard = new MetricsDashboard();
dashboard.start();

Optimizing Web Vitals

Improving LCP

// 1. Optimize server response time
// - Use CDN
// - Implement caching
// - Optimize database queries

// 2. Reduce CSS blocking time
// - Inline critical CSS
// - Defer non-critical CSS

// 3. Reduce JavaScript blocking time
// - Code split
// - Lazy load
// - Defer non-critical scripts

// 4. Optimize images
// - Use modern formats (WebP)
// - Responsive images
// - Lazy load images

// Example: Lazy load images
const images = document.querySelectorAll('img[data-src]');
const imageObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src;
      imageObserver.unobserve(img);
    }
  });
});

images.forEach(img => imageObserver.observe(img));

Improving FID

// 1. Break up long tasks
// - Use requestIdleCallback
// - Use setTimeout to yield to browser

// 2. Use web workers
// - Offload heavy computation
// - Keep main thread responsive

// 3. Reduce JavaScript
// - Code split
// - Remove unused code
// - Use smaller libraries

// Example: Break up long task
function heavyComputation() {
  const tasks = [];
  
  // Split work into chunks
  for (let i = 0; i < 1000; i++) {
    tasks.push(() => {
      // Do work
      Math.sqrt(i);
    });
  }
  
  // Process tasks with delays
  let taskIndex = 0;
  
  function processTasks() {
    const startTime = performance.now();
    
    while (taskIndex < tasks.length && performance.now() - startTime < 5) {
      tasks[taskIndex]();
      taskIndex++;
    }
    
    if (taskIndex < tasks.length) {
      setTimeout(processTasks, 0);
    }
  }
  
  processTasks();
}

Improving CLS

// 1. Avoid layout shifts
// - Set size for images and videos
// - Avoid inserting content above existing content
// - Use transform instead of layout properties

// 2. Use font-display: swap
// - Prevent font swap layout shift

// 3. Avoid animations that cause layout shifts
// - Use transform and opacity
// - Avoid animating layout properties

// Example: Prevent image layout shift
// ✅ Good: Set dimensions
<img src="image.jpg" width="400" height="300" />

// ❌ Bad: No dimensions
<img src="image.jpg" />

// Example: Use transform instead of position
// ✅ Good: Use transform
element.style.transform = 'translateX(10px)';

// ❌ Bad: Use left property
element.style.left = '10px';

Performance Budgets

Setting Budgets

// performance-budget.json
{
  "bundles": [
    {
      "name": "main",
      "maxSize": "250kb"
    },
    {
      "name": "vendor",
      "maxSize": "200kb"
    }
  ],
  "metrics": [
    {
      "name": "LCP",
      "maxValue": 2500
    },
    {
      "name": "FID",
      "maxValue": 100
    },
    {
      "name": "CLS",
      "maxValue": 0.1
    }
  ]
}

Enforcing Budgets

// scripts/check-performance-budget.js
const fs = require('fs');
const path = require('path');

function checkPerformanceBudget() {
  const budget = JSON.parse(
    fs.readFileSync('performance-budget.json', 'utf8')
  );
  
  const metrics = {
    LCP: 2300,
    FID: 95,
    CLS: 0.08
  };
  
  let passed = true;
  
  budget.metrics.forEach(metric => {
    if (metrics[metric.name] > metric.maxValue) {
      console.error(`❌ ${metric.name} exceeds budget: ${metrics[metric.name]} > ${metric.maxValue}`);
      passed = false;
    } else {
      console.log(`✅ ${metric.name} within budget: ${metrics[metric.name]} < ${metric.maxValue}`);
    }
  });
  
  if (!passed) {
    process.exit(1);
  }
}

checkPerformanceBudget();

Monitoring in Production

Analytics Integration

// Send metrics to analytics service
class AnalyticsReporter {
  constructor(endpoint) {
    this.endpoint = endpoint;
  }

  reportMetric(name, value) {
    // Use sendBeacon for reliability
    const data = JSON.stringify({
      name,
      value,
      url: window.location.href,
      timestamp: Date.now()
    });
    
    navigator.sendBeacon(this.endpoint, data);
  }

  reportWebVitals() {
    import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
      getCLS(metric => this.reportMetric('CLS', metric.value));
      getFID(metric => this.reportMetric('FID', metric.value));
      getFCP(metric => this.reportMetric('FCP', metric.value));
      getLCP(metric => this.reportMetric('LCP', metric.value));
      getTTFB(metric => this.reportMetric('TTFB', metric.value));
    });
  }
}

// Usage
const reporter = new AnalyticsReporter('/api/metrics');
reporter.reportWebVitals();

Best Practices

  1. Monitor all Web Vitals:
    // ✅ Good
    getCLS(sendMetric);
    getFID(sendMetric);
    getLCP(sendMetric);
    ```javascript
    
  2. Set performance budgets:
    // ✅ Good
    // LCP < 2.5s, FID < 100ms, CLS < 0.1
    ```javascript
    
  3. Optimize for real users:
    // ✅ Good
    // Monitor in production
    // Use real user metrics
    ```javascript
    
  4. Test on slow networks:
    // ✅ Good
    // Test on 3G, 4G networks
    // Use DevTools throttling
    ```javascript
    

Common Mistakes

  1. Only testing on fast networks:
    // ❌ Bad - only test on fast connection
    // ✅ Good - test on slow networks too
    ```javascript
    
  2. Ignoring real user metrics:
    // ❌ Bad - only use lab metrics
    // ✅ Good - monitor real users
    ```javascript
    
  3. Not setting budgets:
    // ❌ Bad - no performance targets
    // ✅ Good - set and enforce budgets
    

Summary

Web Vitals are essential for user experience. Key takeaways:

  • Measure LCP (< 2.5s), FID (< 100ms), CLS (< 0.1)
  • Monitor in production with real users
  • Set and enforce performance budgets
  • Optimize for slow networks
  • Use web-vitals library for easy measurement
  • Report metrics to analytics
  • Continuously improve metrics

Next Steps

Resources

Comments

Share this article

Scan to read on mobile