Core Web Vitals | Blue Frog Docs

Core Web Vitals

Complete guide to understanding, measuring, and optimizing Core Web Vitals (LCP, INP, CLS) for better SEO rankings and user experience.

Core Web Vitals

Core Web Vitals are Google's standardized metrics for measuring real-world user experience on web pages. These metrics directly influence search rankings and serve as the foundation for understanding and improving page performance.

The Three Core Web Vitals

Largest Contentful Paint (LCP)

LCP measures how long it takes for the largest content element in the viewport to load. This is typically:

  • A hero image
  • A large text block
  • A video thumbnail
LCP Score Rating
≤ 2.5s Good
2.5s - 4s Needs Improvement
> 4s Poor

Common causes of poor LCP:

Interaction to Next Paint (INP)

INP replaced First Input Delay (FID) in March 2024. It measures responsiveness by tracking the latency of all clicks, taps, and keyboard interactions throughout the entire page visit, then reports the worst interaction (excluding outliers).

INP Score Rating
≤ 200ms Good
200ms - 500ms Needs Improvement
> 500ms Poor

Common causes of poor INP:

  • Long-running JavaScript tasks
  • Large DOM size
  • Heavy event handlers
  • Main thread blocking

Cumulative Layout Shift (CLS)

CLS measures visual stability by tracking how much the page layout shifts unexpectedly during loading. A layout shift occurs when a visible element changes position between rendered frames.

CLS Score Rating
≤ 0.1 Good
0.1 - 0.25 Needs Improvement
> 0.25 Poor

Common causes of poor CLS:

  • Images without dimensions
  • Ads, embeds, and iframes without reserved space
  • Dynamically injected content
  • Web fonts causing FOIT/FOUT

Measuring Core Web Vitals

Field Data (Real User Metrics)

Field data comes from actual users visiting your site:

Google Search Console:

  1. Navigate to Core Web Vitals report
  2. View mobile and desktop metrics separately
  3. See URLs grouped by similar experiences
  4. Track improvements over time

Chrome User Experience Report (CrUX):

// Access CrUX data via the CrUX API
const url = 'https://chromeuxreport.googleapis.com/v1/records:queryRecord';
const apiKey = 'YOUR_API_KEY';

fetch(`${url}?key=${apiKey}`, {
  method: 'POST',
  body: JSON.stringify({
    url: 'https://example.com/',
    metrics: ['largest_contentful_paint', 'interaction_to_next_paint', 'cumulative_layout_shift']
  })
})
.then(response => response.json())
.then(data => console.log(data));

PageSpeed Insights:

Lab Data (Simulated)

Lab data helps debug issues in controlled conditions:

Lighthouse (in Chrome DevTools):

  1. Open DevTools (F12)
  2. Navigate to Lighthouse tab
  3. Select "Performance" category
  4. Run audit

Web Vitals JavaScript Library:

<script type="module">
  import {onCLS, onINP, onLCP} from 'https://unpkg.com/web-vitals@4?module';

  function sendToAnalytics(metric) {
    console.log(metric.name, metric.value, metric.rating);

    // Send to Google Analytics
    gtag('event', metric.name, {
      value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
      event_category: 'Web Vitals',
      event_label: metric.id,
      non_interaction: true,
    });
  }

  onCLS(sendToAnalytics);
  onINP(sendToAnalytics);
  onLCP(sendToAnalytics);
</script>

Optimizing LCP

1. Optimize Server Response Time

Target: Time to First Byte (TTFB) < 800ms

# Nginx: Enable compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
gzip_min_length 256;

# Enable caching
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

2. Optimize LCP Element Loading

Preload the LCP image:

<head>
  <!-- Preload the hero image -->
  <link rel="preload" as="image" href="/hero.webp" fetchpriority="high">
</head>

Use responsive images:

<img
  src="hero-800.webp"
  srcset="hero-400.webp 400w, hero-800.webp 800w, hero-1200.webp 1200w"
  sizes="(max-width: 600px) 400px, (max-width: 900px) 800px, 1200px"
  alt="Hero image"
  width="1200"
  height="600"
  fetchpriority="high"
/>

3. Eliminate Render-Blocking Resources

Defer non-critical JavaScript:

<!-- Critical JS inline -->
<script>
  // Minimal inline critical JS
</script>

<!-- Non-critical JS deferred -->
<script src="main.js" defer></script>

Load CSS efficiently:

<!-- Critical CSS inline -->
<style>
  /* Above-the-fold styles */
  .hero { ... }
  .nav { ... }
</style>

<!-- Non-critical CSS async -->
<link rel="preload" href="styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="styles.css"></noscript>

Optimizing INP

1. Break Up Long Tasks

JavaScript tasks blocking the main thread for >50ms hurt INP:

// BAD: Long synchronous task
function processLargeArray(items) {
  items.forEach(item => heavyProcessing(item));
}

// GOOD: Yield to main thread periodically
async function processLargeArray(items) {
  for (let i = 0; i < items.length; i++) {
    heavyProcessing(items[i]);

    // Yield every 50 items
    if (i % 50 === 0) {
      await new Promise(resolve => setTimeout(resolve, 0));
    }
  }
}

// BETTER: Use requestIdleCallback
function processLargeArray(items) {
  let index = 0;

  function processChunk(deadline) {
    while (index < items.length && deadline.timeRemaining() > 0) {
      heavyProcessing(items[index]);
      index++;
    }

    if (index < items.length) {
      requestIdleCallback(processChunk);
    }
  }

  requestIdleCallback(processChunk);
}

2. Optimize Event Handlers

// BAD: Heavy work in click handler
button.addEventListener('click', () => {
  // Heavy synchronous work
  processData();
  updateUI();
  sendAnalytics();
});

// GOOD: Minimal work, defer the rest
button.addEventListener('click', () => {
  // Immediate visual feedback
  button.classList.add('clicked');

  // Defer heavy work
  requestAnimationFrame(() => {
    processData();
    updateUI();
  });

  // Analytics can be async
  queueMicrotask(() => sendAnalytics());
});

3. Reduce DOM Size

Large DOMs slow down interactions:

// Check your DOM size
console.log('DOM elements:', document.querySelectorAll('*').length);
// Target: < 1,500 elements
// Warning: > 3,000 elements

Strategies:

  • Use virtualization for long lists (react-window, vue-virtual-scroller)
  • Lazy load off-screen content
  • Remove hidden elements from DOM instead of hiding with CSS

Optimizing CLS

1. Always Set Image Dimensions

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

<!-- GOOD: Explicit dimensions -->
<img src="photo.jpg" alt="Photo" width="800" height="600">

<!-- GOOD: CSS aspect ratio -->
<style>
  .responsive-img {
    aspect-ratio: 16 / 9;
    width: 100%;
    height: auto;
  }
</style>
<img src="photo.jpg" alt="Photo" class="responsive-img">

2. Reserve Space for Dynamic Content

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

/* Reserve space for embeds */
.video-embed {
  aspect-ratio: 16 / 9;
  width: 100%;
}

3. Avoid Inserting Content Above Existing Content

// BAD: Prepending content causes layout shift
container.prepend(newElement);

// GOOD: Append below viewport, or use transforms
container.append(newElement);

// BETTER: Use CSS transforms (don't cause layout shifts)
element.style.transform = 'translateY(-100px)';

4. Handle Web Fonts Properly

/* Use font-display to control loading behavior */
@font-face {
  font-family: 'CustomFont';
  src: url('custom-font.woff2') format('woff2');
  font-display: swap; /* Show fallback immediately, swap when loaded */
}

/* Or use optional for non-critical fonts */
@font-face {
  font-family: 'DecorativeFont';
  src: url('decorative.woff2') format('woff2');
  font-display: optional; /* Only use if already cached */
}

Match fallback font metrics:

/* Adjust fallback to match custom font */
@font-face {
  font-family: 'CustomFont';
  src: url('custom-font.woff2') format('woff2');
  font-display: swap;
  size-adjust: 105%;
  ascent-override: 90%;
  descent-override: 20%;
}

Monitoring Core Web Vitals in Production

Set Up Real User Monitoring

// Send to your analytics platform
import {onCLS, onINP, onLCP} from 'web-vitals';

function sendToAnalytics({name, value, rating, id, attribution}) {
  // Send to GA4
  gtag('event', name, {
    value: Math.round(name === 'CLS' ? value * 1000 : value),
    metric_id: id,
    metric_rating: rating,
    metric_value: value,
    // Include attribution for debugging
    debug_target: attribution?.element || attribution?.largestShiftTarget
  });
}

onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);

Create Custom Alerts

Set up alerts for regressions:

  1. In GA4:

    • Create custom events for each Web Vital
    • Build exploration reports by page/device
    • Monitor 75th percentile thresholds
  2. Third-party tools:

    • SpeedCurve
    • Calibre
    • WebPageTest

Common Pitfalls

  1. Optimizing only for lab data - Lab tests don't reflect real-world conditions
  2. Ignoring mobile - Mobile users often have different experiences
  3. A/B testing without monitoring - Tests can impact Core Web Vitals
  4. Third-party scripts - Analytics, chat widgets, and ads often hurt performance
  5. Not testing after deployment - Performance can degrade over time

Checklist

  • LCP ≤ 2.5s on 75th percentile of page loads
  • INP ≤ 200ms on 75th percentile of interactions
  • CLS ≤ 0.1 on 75th percentile of page loads
  • All images have width/height attributes
  • Critical CSS is inlined
  • Non-critical JS is deferred
  • Fonts use font-display: swap
  • Real User Monitoring is configured
  • Search Console shows "Good" status
// SYS.FOOTER