Front-end Engineering Lab

Core Web Vitals

Understanding and optimizing Google's Core Web Vitals metrics - LCP, INP, and CLS

Core Web Vitals

Core Web Vitals are a set of real-world user experience metrics that Google uses to measure the quality of a web page. They're part of Google's Page Experience ranking factors and directly impact your SEO rankings.

What Are Core Web Vitals?

Core Web Vitals measure three aspects of user experience:

  1. Loading Performance - How fast content appears
  2. Interactivity - How quickly the page responds to user input
  3. Visual Stability - How stable the page is as it loads

These metrics are measured using real user data (Real User Monitoring - RUM), not synthetic tests. Google collects data from Chrome users who have opted into sharing usage statistics.

Why Core Web Vitals Matter

SEO Impact

  • Search Ranking: Core Web Vitals are ranking factors in Google Search
  • Page Experience Signal: Part of Google's Page Experience update
  • Mobile-First: Especially important for mobile search rankings

User Experience Impact

  • Bounce Rate: Poor Core Web Vitals = higher bounce rate
  • Engagement: Fast, responsive sites = better user engagement
  • Conversions: Better performance = higher conversion rates
  • User Satisfaction: Users expect fast, smooth experiences

Business Impact

Good Core Web Vitals:
✅ Better SEO rankings
✅ Lower bounce rate
✅ Higher conversions
✅ Better user satisfaction
✅ Competitive advantage

The Core Web Vitals

1. Largest Contentful Paint (LCP)

What it measures: Loading performance - how long it takes for the largest content element to be visible.

Target: < 2.5 seconds
Needs improvement: 2.5 - 4.0 seconds
Poor: > 4.0 seconds

Understanding LCP

LCP measures the time from when the page starts loading until the largest content element in the viewport is rendered. This is usually:

  • Hero images (most common)
  • Large text blocks (headings, paragraphs)
  • Hero videos (with poster image)
  • Background images (with text overlay)

How LCP is Calculated

LCP = Time when largest content element is rendered
     - Time when page navigation started

The browser identifies the largest element by:

  1. Measuring all visible elements
  2. Finding the largest one (by area)
  3. Recording when it finishes rendering

What Affects LCP

Server Response Time:

  • Slow server = slow LCP
  • Server-side rendering helps
  • CDN reduces latency
  • Target: < 600ms TTFB (Time to First Byte)

Resource Load Time:

  • Large images slow LCP
  • Render-blocking CSS/JS delays LCP
  • Font loading can delay text LCP

Client-Side Rendering:

  • JavaScript execution delays LCP
  • Heavy frameworks increase LCP
  • Client-side routing affects LCP

Example LCP Timeline

0ms     → Page navigation starts
100ms   → HTML arrives
200ms   → CSS starts loading
400ms   → CSS finishes, rendering starts
800ms   → Hero image starts loading
1200ms  → Hero image finishes loading
1200ms  → LCP! (Hero image is largest element)

Common LCP Elements

// Hero image (most common)
<img src="/hero.jpg" alt="Hero" />

// Large text block
<h1>Welcome to our site</h1>

// Hero video
<video poster="/hero-poster.jpg">
  <source src="/hero.mp4" />
</video>

Optimizing LCP

1. Identify LCP Element

// Find 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.renderTime || lastEntry.loadTime);
}).observe({ type: 'largest-contentful-paint', buffered: true });

2. Prioritize LCP Resource

// Next.js Image with priority
import Image from 'next/image';

<Image
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={600}
  priority  // Loads immediately, no lazy loading
  quality={85}
/>

3. Preload LCP Image

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

4. Optimize Image Size

// Responsive images
<Image
  src="/hero.jpg"
  srcSet="/hero-640w.webp 640w,
          /hero-1280w.webp 1280w,
          /hero-1920w.webp 1920w"
  sizes="100vw"
  alt="Hero"
/>

5. Use Modern Formats

<picture>
  <source srcSet="/hero.avif" type="image/avif" />
  <source srcSet="/hero.webp" type="image/webp" />
  <img src="/hero.jpg" alt="Hero" />
</picture>

6. Eliminate Render-Blocking Resources

<!-- Inline critical CSS -->
<style>
  .hero { height: 100vh; background: #000; }
</style>

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

7. Use CDN

// Serve images from CDN
<Image
  src="https://cdn.example.com/hero.jpg"
  alt="Hero"
  priority
/>

8. Improve Server Response Time

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

Strategies:
- Use CDN
- Enable compression
- Optimize database queries
- Use caching
- Use HTTP/2

2. Interaction to Next Paint (INP)

What it measures: Interactivity - how quickly the page responds to user input.

INP Target: < 200ms
Needs improvement: 200 - 500ms
Poor: > 500ms

Note: INP replaced FID (First Input Delay) in 2024. FID only measured the first interaction, while INP measures all interactions.

Understanding INP

INP measures interactivity - how quickly the page responds to user input. It's the successor to FID (First Input Delay), which was deprecated in 2024.

INP measures:

  • Click events
  • Keyboard events (typing, keydown)
  • Tap events (mobile)

INP does NOT measure:

  • Scroll events
  • Hover events
  • Passive interactions

How INP is Calculated

INP = Time from user interaction to next paint (visual feedback)

Total INP = Input Delay + Processing Time + Presentation Delay

The browser measures:

  1. Input delay: Time from interaction to event handler starts
  2. Processing time: Time event handler takes to execute
  3. Presentation delay: Time from handler finish to next paint

What Affects INP

Long Tasks:

  • Heavy JavaScript execution blocks main thread
  • Large bundle sizes increase parse/execute time
  • Third-party scripts add overhead

Event Handler Performance:

  • Slow event handlers increase INP
  • Complex calculations in handlers
  • Synchronous operations block interaction

Main Thread Blocking:

  • Layout thrashing
  • Style recalculations
  • Paint operations

Example INP Timeline

0ms     → User clicks button
50ms    → Input delay (main thread busy)
50ms    → Event handler starts
150ms   → Event handler finishes
160ms   → Browser paints update
160ms   → INP = 160ms ✅ (Good!)

FID vs INP

FID (Deprecated):

  • Only measured first interaction
  • Only measured delay (not processing)
  • Limited scope

INP (Current):

  • Measures all interactions
  • Measures full interaction lifecycle
  • Better reflects real user experience
  • More comprehensive

Optimizing INP

1. Identify Long Tasks

// Monitor long tasks (> 50ms)
new PerformanceObserver((list) => {
  list.getEntries().forEach((entry) => {
    if (entry.duration > 50) {
      console.log('Long task:', {
        duration: entry.duration,
        startTime: entry.startTime,
      });
    }
  });
}).observe({ type: 'longtask', buffered: true });

2. Break Up Long Tasks

// ❌ BAD: Long synchronous task
function processData(data: any[]) {
  data.forEach(item => {
    // Heavy processing
    heavyCalculation(item);
  });
}

// ✅ GOOD: Break into smaller chunks
async function processData(data: any[]) {
  const chunks = chunkArray(data, 100);
  
  for (const chunk of chunks) {
    await new Promise(resolve => setTimeout(resolve, 0));
    chunk.forEach(item => {
      heavyCalculation(item);
    });
  }
}

3. Use Web Workers

// heavy-calculation.worker.ts
self.addEventListener('message', (e) => {
  const result = heavyCalculation(e.data);
  self.postMessage(result);
});

// Main thread
const worker = new Worker(new URL('./heavy-calculation.worker', import.meta.url));

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

4. Debounce/Throttle Input Handlers

import { debounce, throttle } from 'lodash';

// Debounce search (wait for user to stop typing)
const handleSearch = debounce((query: string) => {
  // Expensive search
  performSearch(query);
}, 300);

// Throttle scroll (limit frequency)
const handleScroll = throttle(() => {
  // Update scroll position
  updateScrollPosition();
}, 100);

5. Defer Non-Critical JavaScript

import { lazy, Suspense } from 'react';

// Load heavy component only when needed
const HeavyComponent = lazy(() => import('./HeavyComponent'));

function App() {
  return (
    <Suspense fallback={<Loading />}>
      <HeavyComponent />
    </Suspense>
  );
}

6. Use requestIdleCallback

// Run non-critical work when browser is idle
requestIdleCallback(() => {
  // Non-critical analytics
  trackAnalytics();
}, { timeout: 2000 });

7. Optimize Third-Party Scripts

// Load third-parties after page interactive
<Script
  src="https://third-party.com/script.js"
  strategy="afterInteractive"
/>

3. Cumulative Layout Shift (CLS)

What it measures: Visual stability - how much the page shifts during loading.

Target: < 0.1
Needs improvement: 0.1 - 0.25
Poor: > 0.25

Understanding CLS

CLS measures unexpected layout shifts - when elements move around as the page loads. This creates a jarring user experience.

CLS measures:

  • Elements that shift position
  • Elements that change size
  • Elements that appear/disappear

CLS does NOT measure:

  • User-initiated shifts (scrolling, clicking)
  • Expected animations
  • Transitions

How CLS is Calculated

CLS = Sum of all layout shift scores

Layout Shift Score = Impact Fraction × Distance Fraction

Impact Fraction = Area of viewport affected by shift
Distance Fraction = Distance element moved (as % of viewport)

Example:

Element shifts:
- Impact Fraction: 0.5 (50% of viewport)
- Distance Fraction: 0.3 (moved 30% of viewport height)

Layout Shift Score = 0.5 × 0.3 = 0.15

If this happens 3 times:
CLS = 0.15 + 0.10 + 0.08 = 0.33 ❌ (Poor!)

What Causes Layout Shifts

Images Without Dimensions:

<!-- ❌ BAD: Causes layout shift -->
<img src="/image.jpg" alt="Image" />

<!-- ✅ GOOD: Prevents layout shift -->
<img src="/image.jpg" alt="Image" width="800" height="600" />

Fonts Loading:

/* ❌ BAD: Text shifts when font loads */
.text {
  font-family: 'Custom Font', sans-serif;
}

/* ✅ GOOD: Reserve space with font-display */
@font-face {
  font-family: 'Custom Font';
  font-display: swap; /* Prevents invisible text */
}

Dynamic Content:

// ❌ BAD: Content appears, shifts layout
{data && <div>{data.content}</div>}

// ✅ GOOD: Reserve space with skeleton
{data ? (
  <div>{data.content}</div>
) : (
  <div style={{ height: '200px' }}>Loading...</div>
)}

Ads/Embeds:

<!-- ❌ BAD: Ad loads, shifts content -->
<div id="ad-container"></div>

<!-- ✅ GOOD: Reserve space for ad -->
<div id="ad-container" style="min-height: 250px;">
  <div>Loading ad...</div>
</div>

Common CLS Issues

  1. Images without width/height
  2. Fonts without font-display: swap
  3. Ads/embeds without reserved space
  4. Dynamically inserted content
  5. Animations that trigger layout

Optimizing CLS

1. Measure CLS

let clsScore = 0;

new PerformanceObserver((list) => {
  list.getEntries().forEach((entry: any) => {
    if (!entry.hadRecentInput) {
      clsScore += entry.value;
      console.log('Layout shift:', entry.value);
      console.log('Total CLS:', clsScore);
    }
  });
}).observe({ type: 'layout-shift', buffered: true });

2. Set Dimensions on Images/Videos

// ❌ BAD: No dimensions (causes layout shift)
<img src="/image.jpg" alt="Image" />

// ✅ GOOD: Explicit dimensions
<img src="/image.jpg" alt="Image" width="800" height="600" />

// ✅ GOOD: Next.js Image
<Image
  src="/image.jpg"
  alt="Image"
  width={800}
  height={600}
/>

3. Reserve Space for Ads/Embeds

// Reserve space for ad
<div style={{ minHeight: '250px' }}>
  <AdComponent />
</div>

// Reserve space for YouTube
<div style={{ aspectRatio: '16 / 9' }}>
  <YouTubeEmbed videoId="..." />
</div>

4. Avoid Inserting Content Above Existing Content

// ❌ BAD: Inserts banner, shifts content down
function App() {
  const [showBanner, setShowBanner] = useState(false);
  
  useEffect(() => {
    setTimeout(() => setShowBanner(true), 2000);
  }, []);
  
  return (
    <>
      {showBanner && <Banner />}  {/* Shifts content */}
      <Content />
    </>
  );
}

// ✅ GOOD: Overlay doesn't shift content
function App() {
  return (
    <>
      <Banner style={{ position: 'fixed', top: 0 }} />
      <Content style={{ marginTop: '60px' }} />
    </>
  );
}

5. Use CSS Transform

/* ❌ BAD: Causes layout shift */
.box {
  transition: width 0.3s;
}
.box:hover {
  width: 200px;
}

/* ✅ GOOD: No layout shift */
.box {
  transition: transform 0.3s;
}
.box:hover {
  transform: scaleX(1.2);
}

6. Preload Fonts

<link
  rel="preload"
  href="/fonts/inter.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter.woff2') format('woff2');
  font-display: optional;  /* Or swap with size-adjust */
}

7. Use size-adjust

/* Fallback font matches custom font size */
@font-face {
  font-family: 'Inter Fallback';
  src: local('Arial');
  size-adjust: 105%;  /* Adjust to match Inter */
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

body {
  font-family: 'Inter', 'Inter Fallback', sans-serif;
}

Measuring Core Web Vitals

1. Chrome DevTools

Lighthouse:

1. Open Chrome DevTools
2. Go to Lighthouse tab
3. Select "Performance"
4. Click "Analyze page load"
5. View Core Web Vitals in report

Performance Panel:

1. Open Chrome DevTools
2. Go to Performance tab
3. Record page load
4. Look for Web Vitals markers

2. Web Vitals Library

npm install web-vitals
import { onLCP, onINP, onCLS } from 'web-vitals';

// Measure LCP
onLCP((metric) => {
  console.log('LCP:', metric.value);
  sendToAnalytics(metric);
});

// Measure INP
onINP((metric) => {
  console.log('INP:', metric.value);
  sendToAnalytics(metric);
});

// Measure CLS
onCLS((metric) => {
  console.log('CLS:', metric.value);
  sendToAnalytics(metric);
});

// Send to analytics
function sendToAnalytics(metric: any) {
  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,
  });
}

3. Next.js Integration

// app/layout.tsx
import { SpeedInsights } from '@vercel/speed-insights/next';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <SpeedInsights />
      </body>
    </html>
  );
}

4. Custom Reporting

// lib/web-vitals.ts
export function reportWebVitals(metric: any) {
  switch (metric.name) {
    case 'FCP':
      console.log('First Contentful Paint:', metric.value);
      break;
    case 'LCP':
      console.log('Largest Contentful Paint:', metric.value);
      break;
    case 'CLS':
      console.log('Cumulative Layout Shift:', metric.value);
      break;
    case 'INP':
      console.log('Interaction to Next Paint:', metric.value);
      break;
    case 'TTFB':
      console.log('Time to First Byte:', metric.value);
      break;
  }
  
  // Send to your analytics
  fetch('/api/vitals', {
    method: 'POST',
    body: JSON.stringify(metric),
    headers: { 'Content-Type': 'application/json' },
  });
}

5. Real User Monitoring (RUM)

Google Analytics:

  • Automatically collects Core Web Vitals
  • Shows in Google Search Console
  • Real user data, not synthetic

Third-Party Tools:

  • New Relic
  • Datadog
  • Sentry
  • Custom RUM solutions

6. PageSpeed Insights

1. Go to https://pagespeed.web.dev/
2. Enter your URL
3. View Core Web Vitals scores
4. Get optimization suggestions

7. Google Search Console

1. Go to Google Search Console
2. Navigate to "Core Web Vitals" report
3. View field data (real users)
4. See which pages need improvement

8. Web Vitals Extension

Install: Chrome Web Store → "Web Vitals"
Shows real-time CLS, LCP, INP

Core Web Vitals Targets

Summary Table

MetricGoodNeeds ImprovementPoor
LCP< 2.5s2.5s - 4.0s> 4.0s
INP< 200ms200ms - 500ms> 500ms
CLS< 0.10.1 - 0.25> 0.25

Percentile Targets

Google uses the 75th percentile (p75) for Core Web Vitals:

  • 75% of users should experience "Good" scores
  • 25% of users can have "Needs Improvement" or "Poor"
  • This accounts for varying network conditions and devices

Field Data vs Lab Data

Field Data (Real Users):

  • Real user measurements
  • Various devices/networks
  • Used for SEO rankings
  • More accurate for business impact

Lab Data (Synthetic):

  • Controlled environment
  • Consistent conditions
  • Good for development
  • May not reflect real users

Best Practice: Optimize based on field data from real users.


Quick Reference Checklists

LCP Checklist

  • Identify LCP element
  • Optimize LCP resource (image, text, video)
  • Preload critical resources
  • Reduce server response time (< 600ms TTFB)
  • Minimize render-blocking resources
  • Use CDN for static assets
  • Optimize images (format, size, priority)
  • Use modern image formats (WebP, AVIF)

INP Checklist

  • Reduce JavaScript bundle size
  • Code split heavy components
  • Optimize event handlers
  • Avoid long tasks (> 50ms)
  • Use Web Workers for heavy computation
  • Minimize third-party scripts
  • Defer non-critical JavaScript
  • Debounce/throttle input handlers
  • Optimize main thread usage

CLS Checklist

  • Add width/height to images
  • Use font-display: swap for fonts
  • Reserve space for ads/embeds
  • Avoid inserting content above existing content
  • Use skeleton screens for dynamic content
  • Prefer transform/opacity for animations
  • Use size-adjust for fonts
  • Test layout stability

Best Practices Summary

LCP Best Practices

  1. Preload hero image
  2. Use CDN
  3. Optimize image size
  4. Eliminate render-blocking resources
  5. Fast server response (< 600ms)
  6. Use modern image formats

INP Best Practices

  1. Break up long tasks
  2. Use web workers
  3. Defer third-party scripts
  4. Code splitting
  5. Throttle/debounce handlers
  6. Optimize event handlers

CLS Best Practices

  1. Set image/video dimensions
  2. Reserve space for dynamic content
  3. Preload fonts with size-adjust
  4. Use transform instead of layout props
  5. Avoid inserting content above fold

Common Pitfalls

No image dimensions: CLS
Always set width/height

Hero image lazy loaded: Slow LCP
Use priority on LCP images

Heavy JavaScript on main thread: Poor INP
Break tasks, use web workers

Fonts cause layout shift: Bad CLS
Preload + size-adjust

Third-parties in <head>: Everything slow
Load after interactive


Common Questions

Q: Which metric is most important?

A: All three are important, but LCP often has the biggest impact on user perception and SEO. However, Google considers all three in rankings.

Q: Do I need perfect scores?

A: No. Aim for "Good" scores (75th percentile). Perfect scores are often impractical and may not provide significant benefit.

Q: How often should I measure?

A:

  • During development: Use Lighthouse in DevTools
  • In production: Monitor field data continuously
  • After changes: Measure before/after optimizations

Q: Can I optimize all three at once?

A: Yes! Many optimizations help multiple metrics:

  • Code splitting → Better LCP and INP
  • Image optimization → Better LCP and CLS
  • Reducing JavaScript → Better LCP and INP

Q: What if my scores are poor?

A:

  1. Identify the biggest issue (usually LCP)
  2. Focus on that first
  3. Measure impact of changes
  4. Iterate and improve

Next Steps

Learn more about specific optimization techniques:

Remember: Core Web Vitals measure real user experience. Focus on making your site fast, responsive, and stable for actual users, not just synthetic tests.

On this page