Skip to main content

Core Web Vitals Metrics

Core Web Vitals are a set of three metrics that measure loading performance, interactivity, and visual stability. Google uses these metrics as page experience signals for search ranking.

Overview

Google measures Core Web Vitals at the 75th percentile — meaning 75% of page visits must meet “Good” thresholds for a page to pass.
MetricMeasuresGoodNeeds ImprovementPoor
LCPLoading≤ 2.5s2.5s – 4.0s> 4.0s
INPInteractivity≤ 200ms200ms – 500ms> 500ms
CLSVisual Stability≤ 0.10.1 – 0.25> 0.25

LCP: Largest Contentful Paint

What is LCP?

Largest Contentful Paint (LCP) measures when the largest content element in the viewport becomes visible. This is typically:
  • An <img> element
  • An <image> element inside <svg>
  • A <video> element with poster image
  • An element with a background image via url()
  • A block-level element containing text nodes

LCP Timeline

[  Server Response  ][  Resource Load  ][  Render  ]
       TTFB              Download         Paint
       └─────────────────────────────────────┘
                         LCP Time

Common LCP Issues

1. Slow Server Response (TTFB > 800ms)

Target: < 800ms Solutions:
  • Use CDN for edge caching
  • Implement stale-while-revalidate caching
  • Optimize database queries
  • Use edge functions for dynamic content
// Cache-Control header example
res.setHeader('Cache-Control', 's-maxage=60, stale-while-revalidate=300');

2. Render-Blocking Resources

<!-- ❌ Blocks rendering -->
<link rel="stylesheet" href="/all-styles.css">

<!-- ✅ Critical CSS inlined, rest deferred -->
<style>/* Critical above-fold CSS */</style>
<link rel="preload" href="/styles.css" as="style" 
      onload="this.onload=null;this.rel='stylesheet'">

3. Slow Resource Load Times

<!-- ❌ No hints, discovered late -->
<img src="/hero.jpg" alt="Hero">

<!-- ✅ Preloaded with high priority -->
<link rel="preload" href="/hero.webp" as="image" fetchpriority="high">
<img src="/hero.webp" alt="Hero" fetchpriority="high">

4. Client-Side Rendering Delays

// ❌ Content loads after JavaScript
useEffect(() => {
  fetch('/api/hero-text').then(r => r.json()).then(setHeroText);
}, []);

// ✅ Server-side or static rendering
export async function getServerSideProps() {
  const heroText = await fetchHeroText();
  return { props: { heroText } };
}

LCP Optimization Checklist

  • TTFB < 800ms (use CDN, edge caching)
  • LCP image preloaded with fetchpriority=“high”
  • LCP image optimized (WebP/AVIF, correct size)
  • Critical CSS inlined (< 14KB)
  • No render-blocking JavaScript in <head>
  • Fonts don’t block text rendering (font-display: swap)
  • LCP element in initial HTML (not JS-rendered)

Debugging LCP

// Find your LCP element
new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const lastEntry = entries[entries.length - 1];
  console.log('LCP element:', lastEntry.element);
  console.log('LCP time:', lastEntry.startTime);
  console.log('LCP size:', lastEntry.size);
  console.log('LCP url:', lastEntry.url);
}).observe({ type: 'largest-contentful-paint', buffered: true });

Common LCP Issues and Impact

IssueImpactFix
No preload for LCP image+500-1000msAdd <link rel="preload">
Large unoptimized image+300-800msCompress, use WebP/AVIF
Render-blocking CSS+200-500msInline critical CSS
Slow TTFB+300-2000msCDN, edge caching
Client-rendered content+500-2000msSSR/SSG

INP: Interaction to Next Paint

What is INP?

INP measures responsiveness across ALL interactions (clicks, taps, key presses) during a page visit. It reports the worst interaction (at 98th percentile for high-traffic pages).

INP Breakdown

Total INP = Input Delay + Processing Time + Presentation Delay
PhaseTargetOptimization
Input Delay< 50msReduce main thread blocking
Processing< 100msOptimize event handlers
Presentation< 50msMinimize rendering work

Common INP Issues

1. Long Tasks Blocking Main Thread

// ❌ Long synchronous task
function processLargeArray(items) {
  items.forEach(item => expensiveOperation(item));
}

// ✅ Break into chunks with yielding
async function processLargeArray(items) {
  const CHUNK_SIZE = 100;
  for (let i = 0; i < items.length; i += CHUNK_SIZE) {
    const chunk = items.slice(i, i + CHUNK_SIZE);
    chunk.forEach(item => expensiveOperation(item));
    
    // Yield to main thread
    await new Promise(r => setTimeout(r, 0));
    // Or use scheduler.yield() when available
  }
}

2. Heavy Event Handlers

// ❌ All work in handler
button.addEventListener('click', () => {
  const result = calculateComplexThing();
  updateUI(result);
  trackEvent('click');
});

// ✅ Prioritize visual feedback
button.addEventListener('click', () => {
  // Immediate visual feedback
  button.classList.add('loading');
  
  // Defer non-critical work
  requestAnimationFrame(() => {
    const result = calculateComplexThing();
    updateUI(result);
  });
  
  // Use requestIdleCallback for analytics
  requestIdleCallback(() => trackEvent('click'));
});

3. Third-Party Scripts

// ❌ Eagerly loaded, blocks interactions
<script src="https://heavy-widget.com/widget.js"></script>

// ✅ Lazy loaded on interaction or visibility
const loadWidget = () => {
  import('https://heavy-widget.com/widget.js')
    .then(widget => widget.init());
};
button.addEventListener('click', loadWidget, { once: true });

4. Excessive Re-renders (React/Vue)

// ❌ Re-renders entire tree
function App() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <Counter count={count} />
      <ExpensiveComponent /> {/* Re-renders on every count change */}
    </div>
  );
}

// ✅ Memoized expensive components
const MemoizedExpensive = React.memo(ExpensiveComponent);

function App() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <Counter count={count} />
      <MemoizedExpensive />
    </div>
  );
}

INP Optimization Checklist

  • No tasks > 50ms on main thread
  • Event handlers complete quickly (< 100ms)
  • Visual feedback provided immediately
  • Heavy work deferred with requestIdleCallback
  • Third-party scripts don’t block interactions
  • Debounced input handlers where appropriate
  • Web Workers for CPU-intensive operations

Debugging INP

// Identify slow interactions
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 200) {
      console.warn('Slow interaction:', {
        type: entry.name,
        duration: entry.duration,
        processingStart: entry.processingStart,
        processingEnd: entry.processingEnd,
        target: entry.target
      });
    }
  }
}).observe({ type: 'event', buffered: true, durationThreshold: 16 });

CLS: Cumulative Layout Shift

What is CLS?

CLS measures unexpected layout shifts. A shift occurs when a visible element changes position between frames without user interaction. CLS Formula: impact fraction × distance fraction

Common CLS Causes

1. Images Without Dimensions

<!-- ❌ Causes layout shift when loaded -->
<img src="photo.jpg" alt="Photo">

<!-- ✅ Space reserved -->
<img src="photo.jpg" alt="Photo" width="800" height="600">

<!-- ✅ Or use aspect-ratio -->
<img src="photo.jpg" alt="Photo" style="aspect-ratio: 4/3; width: 100%;">

2. Ads, Embeds, and Iframes

<!-- ❌ Unknown size until loaded -->
<iframe src="https://ad-network.com/ad"></iframe>

<!-- ✅ Reserve space with min-height -->
<div style="min-height: 250px;">
  <iframe src="https://ad-network.com/ad" height="250"></iframe>
</div>

<!-- ✅ Or use aspect-ratio container -->
<div style="aspect-ratio: 16/9;">
  <iframe src="https://youtube.com/embed/..." 
          style="width: 100%; height: 100%;"></iframe>
</div>

3. Web Fonts Causing FOUT

/* ❌ Font swap shifts text */
@font-face {
  font-family: 'Custom';
  src: url('custom.woff2') format('woff2');
}

/* ✅ Optional font (no shift if slow) */
@font-face {
  font-family: 'Custom';
  src: url('custom.woff2') format('woff2');
  font-display: optional;
}

/* ✅ Or match fallback metrics */
@font-face {
  font-family: 'Custom';
  src: url('custom.woff2') format('woff2');
  font-display: swap;
  size-adjust: 105%; /* Match fallback size */
  ascent-override: 95%;
  descent-override: 20%;
}

4. Animations Triggering Layout

/* ❌ Animates layout properties */
.animate {
  transition: height 0.3s, width 0.3s;
}

/* ✅ Use transform instead */
.animate {
  transition: transform 0.3s;
}
.animate.expanded {
  transform: scale(1.2);
}

CLS Optimization Checklist

  • All images have width/height or aspect-ratio
  • All videos/embeds have reserved space
  • Ads have min-height containers
  • Fonts use font-display: optional or matched metrics
  • Dynamic content inserted below viewport
  • Animations use transform/opacity only
  • No content injected above existing content

Debugging CLS

// Track layout shifts
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) {
      console.log('Layout shift:', entry.value);
      entry.sources?.forEach(source => {
        console.log('  Shifted element:', source.node);
        console.log('  Previous rect:', source.previousRect);
        console.log('  Current rect:', source.currentRect);
      });
    }
  }
}).observe({ type: 'layout-shift', buffered: true });

Measurement Tools

Lab Testing (Synthetic)

  • Chrome DevTools → Performance panel, Lighthouse tab
  • WebPageTest → Detailed waterfall, filmstrip view
  • Lighthouse CLInpx lighthouse <url>

Field Data (Real Users)

  • Chrome User Experience Report (CrUX) → BigQuery or API
  • Google Search Console → Core Web Vitals report
  • web-vitals library → Send to your analytics
import {onLCP, onINP, onCLS} from 'web-vitals';

function sendToAnalytics({name, value, rating}) {
  gtag('event', name, {
    event_category: 'Web Vitals',
    value: Math.round(name === 'CLS' ? value * 1000 : value),
    event_label: rating
  });
}

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

Additional Resources

Build docs developers (and LLMs) love