Core Web Vitals: Measure and Optimize User Experience

Medium

Core Web Vitals directly impact user experience and SEO. Learn how to measure and optimize each metric.

Quick Decision Guide

Quick Optimization Guide:

LCP < 2.5s: Optimize images (WebP, lazy load), use CDN, preload critical resources, reduce server response time.

INP < 200ms: Reduce main-thread contention, break up long tasks, defer non-critical JS, and minimize third-party script impact.

CLS < 0.1: Set width/height on images/videos, reserve space for ads, avoid inserting content above existing content.

Measure: Use Lighthouse, PageSpeed Insights, or web-vitals library to track real-user metrics (RUM).

Largest Contentful Paint (LCP)

What is LCP?

LCP measures loading performance by tracking when the largest visible element renders.

Common LCP Elements

Hero images
Video thumbnails
Large text blocks
Background images

Measuring LCP

import { getLCP } from 'web-vitals';

getLCP((metric) => {
  console.log('LCP:', metric.value, 'ms');
  // Good: < 2500ms
  // Needs improvement: 2500-4000ms
  // Poor: > 4000ms
});

Optimization Strategies

1. Optimize Images

<!-- Use modern formats -->
<picture>
  <source srcset="hero.webp" type="image/webp">
  <source srcset="hero.avif" type="image/avif">
  <img src="hero.jpg" alt="Hero">
</picture>

<!-- Preload LCP image -->
<link rel="preload" as="image" href="hero.jpg">

2. Use CDN

// Instead of: /images/hero.jpg
// Use CDN: https://cdn.example.com/hero.jpg
<img src="https://cdn.example.com/hero.jpg" />

3. Reduce Server Response Time (TTFB)

// Implement caching
app.use((req, res, next) => {
  res.set('Cache-Control', 'public, max-age=31536000');
  next();
});

// Use HTTP/2 or HTTP/3
// Enable compression (gzip, brotli)

4. Remove Render-Blocking Resources

<!-- Defer non-critical CSS -->
<link rel="preload" href="styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">

<!-- Inline critical CSS -->
<style>
  /* Above-the-fold styles */
</style>

Interaction to Next Paint (INP)

What is INP?

INP measures interactivity by tracking the latency of user interactions (click, tap, key press) and reporting a high-percentile interaction delay over the page lifecycle.

> FID is deprecated as a Core Web Vital. Use INP for interactivity evaluation.

Measuring INP

import { getINP } from 'web-vitals';

getINP((metric) => {
  console.log('INP:', metric.value, 'ms');
  // Good: < 200ms
  // Needs improvement: 200-500ms
  // Poor: > 500ms
});

Optimization Strategies

1. Break Up Long Tasks

// ❌ Bad: Long blocking task
function processData(data) {
  for (let i = 0; i < 10000; i++) {
    // Heavy computation
  }
}

// ✅ Good: Chunked processing
async function processData(data) {
  const chunkSize = 100;
  for (let i = 0; i < data.length; i += chunkSize) {
    await new Promise(resolve => setTimeout(resolve, 0));
    // Process chunk
  }
}

2. Use Web Workers

// Offload heavy computation
const worker = new Worker('heavy-computation.js');

worker.postMessage({ data: largeDataset });

worker.onmessage = (e) => {
  console.log('Result:', e.data);
};

3. Defer Non-Critical JavaScript

<!-- Defer analytics -->
<script defer src="analytics.js"></script>

<!-- Load chat widget after interaction -->
<script>
  document.addEventListener('click', () => {
    import('./chat-widget.js');
  }, { once: true });
</script>

4. Reduce Interaction Handler Work

button.addEventListener('click', () => {
  // Keep event handlers short and schedule non-urgent work
  requestAnimationFrame(() => updateUI());
  setTimeout(() => runNonCriticalWork(), 0);
});

Cumulative Layout Shift (CLS)

What is CLS?

CLS measures visual stability by tracking unexpected layout shifts during page load.

Measuring CLS

import { getCLS } from 'web-vitals';

getCLS((metric) => {
  console.log('CLS:', metric.value);
  // Good: < 0.1
  // Needs improvement: 0.1-0.25
  // Poor: > 0.25
});

Common Causes of CLS

1. Images without dimensions

2. Ads/embeds/iframes without reserved space

3. Dynamically injected content

4. Web fonts causing FOIT/FOUT

Optimization Strategies

1. Set Dimensions on Images

<!-- ❌ Bad: No dimensions -->
<img src="photo.jpg" alt="Photo">

<!-- ✅ Good: Fixed dimensions -->
<img src="photo.jpg" width="800" height="600" alt="Photo">

<!-- ✅ Better: Aspect ratio -->
<img 
  src="photo.jpg" 
  style="aspect-ratio: 16/9; width: 100%;" 
  alt="Photo"
>

2. Reserve Space for Ads

.ad-container {
  min-height: 250px; /* Reserve space */
  background: #f0f0f0; /* Placeholder color */
}

3. Avoid Inserting Content Above

// ❌ Bad: Insert above
document.body.prepend(banner);

// ✅ Good: Append or use fixed positioning
document.body.append(banner);
// or
banner.style.position = 'fixed';

4. Optimize Web Fonts

/* Prevent layout shift from font loading */
@font-face {
  font-family: 'Custom Font';
  src: url('font.woff2') format('woff2');
  font-display: optional; /* Use system font if not loaded quickly */
}

/* Or use fallback with similar metrics */
body {
  font-family: 'Custom Font', Arial, sans-serif;
}

Measuring in Production

web-vitals Library

import { getCLS, getINP, getLCP, getFCP, getTTFB } from 'web-vitals';

function sendToAnalytics(metric) {
  // Send to your analytics endpoint
  fetch('/analytics', {
    method: 'POST',
    body: JSON.stringify({
      name: metric.name,
      value: metric.value,
      id: metric.id,
      delta: metric.delta
    })
  });
}

getCLS(sendToAnalytics);
getINP(sendToAnalytics);
getLCP(sendToAnalytics);
getFCP(sendToAnalytics);
getTTFB(sendToAnalytics);

Chrome User Experience Report (CrUX)

// Query CrUX API
async function getCrUXData(url) {
  const response = await fetch(
    `https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=API_KEY`,
    {
      method: 'POST',
      body: JSON.stringify({ url })
    }
  );
  
  const data = await response.json();
  console.log('CrUX data:', data);
}

Performance Observer

// Low-level API
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.entryType === 'largest-contentful-paint') {
      console.log('LCP:', entry.renderTime || entry.loadTime);
    }
    // INP is best measured via the web-vitals library; first-input is legacy/FID-oriented
    if (entry.entryType === 'layout-shift' && !entry.hadRecentInput) {
      console.log('CLS:', entry.value);
    }
  }
});

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

Best Practices

Testing Tools

1. Lighthouse: Automated audits in Chrome DevTools

2. PageSpeed Insights: Google's tool with real-user data

3. WebPageTest: Detailed performance analysis

4. Chrome DevTools: Performance panel

Optimization Checklist

LCP

[ ] Optimize and compress images
[ ] Use CDN for static assets
[ ] Implement lazy loading for below-fold images
[ ] Preload critical resources
[ ] Minimize CSS and JavaScript
[ ] Use server-side rendering or static generation

INP

[ ] Minimize JavaScript execution time
[ ] Break up long tasks (> 50ms)
[ ] Keep input handlers lightweight
[ ] Defer non-critical JavaScript
[ ] Use code splitting
[ ] Remove unused JavaScript

CLS

[ ] Set width/height on images and videos
[ ] Reserve space for ads and embeds
[ ] Avoid inserting content above existing content
[ ] Use CSS transforms for animations (not top/left)
[ ] Use font-display: optional or swap
[ ] Preload fonts

Monitoring

// Set up Real User Monitoring (RUM)
import { getCLS, getINP, getLCP } from 'web-vitals';

const vitalsUrl = 'https://analytics.example.com/vitals';

function sendToAnalytics({ name, delta, value, id }) {
  const body = JSON.stringify({ name, delta, value, id });
  
  // Use sendBeacon for reliability
  if (navigator.sendBeacon) {
    navigator.sendBeacon(vitalsUrl, body);
  } else {
    fetch(vitalsUrl, { body, method: 'POST', keepalive: true });
  }
}

getCLS(sendToAnalytics);
getINP(sendToAnalytics);
getLCP(sendToAnalytics);

Performance Budget

// Set thresholds in your CI/CD
const budgets = {
  LCP: 2500,  // ms
  INP: 200,   // ms
  CLS: 0.1    // score
};

// Fail build if metrics exceed budget
if (metrics.LCP > budgets.LCP) {
  throw new Error('LCP exceeds budget');
}