A

Core Web Vitals & Metrics

performancecore-web-vitalslcpclsinpmetricsseo

Core Web Vitals & Metrics

Core Web Vitals are Google's standardized metrics for measuring user experience. Since June 2021, they've been a ranking factor for search results. More importantly, they correlate with real user satisfaction—pages that score well retain visitors and convert better.

The three Core Web Vitals are:

Metric What It Measures Good Needs Improvement Poor
LCP Loading (main content visible) ≤2.5s 2.5s–4s >4s
CLS Visual stability (layout shifts) ≤0.1 0.1–0.25 >0.25
INP Responsiveness (interaction delay) ≤200ms 200ms–500ms >500ms

These thresholds apply to the 75th percentile of page loads—meaning 75% of your users should experience "good" scores.

LCP: Largest Contentful Paint

LCP measures how long it takes for the main content to become visible. It identifies the largest image, video, or text block in the viewport and reports when it finishes rendering.

What Counts as the LCP Element?

The browser considers these element types:

  • <img> elements
  • <image> elements inside <svg>
  • <video> elements (poster image)
  • Elements with background-image loaded via CSS
  • Block-level text elements (<h1>, <p>, <div> with text)

The "largest" is determined by visible size, not file size. A hero image typically wins, but on text-heavy pages, a heading might be the LCP element.

Diagnosing LCP Issues

Use Chrome DevTools to identify your LCP element:

  1. Open DevTools → Performance tab
  2. Record a page load
  3. Click "Timings" → Look for the "LCP" marker
  4. Hover to see which element triggered it

Or use the Web Vitals extension, which shows the LCP element directly.

Common LCP problems:

Problem Symptom Solution
Slow server response High TTFB (>600ms) Optimize backend, use CDN
Render-blocking resources Large CSS/JS delaying render Inline critical CSS, defer JS
Slow resource load LCP image takes too long Preload, optimize format, use CDN
Client-side rendering Content requires JS to appear Use SSR/SSG

Optimizing LCP

1. Optimize Server Response Time (TTFB)

Time to First Byte directly impacts LCP. Every millisecond of TTFB delays everything.

# Check TTFB with curl
curl -w "TTFB: %{time_starttransfer}s\n" -o /dev/null -s https://example.com

Improvements:

  • Use a CDN to serve content from edge locations
  • Implement server-side caching (see Caching Strategies)
  • Optimize database queries
  • Use HTTP/2 or HTTP/3

2. Preload the LCP Resource

If your LCP element is an image, tell the browser to fetch it early:

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

For responsive images, preload the most likely candidate:

<link
  rel="preload"
  href="/images/hero-800.webp"
  as="image"
  imagesrcset="/images/hero-400.webp 400w, /images/hero-800.webp 800w, /images/hero-1200.webp 1200w"
  imagesizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px"
>

3. Eliminate Render-Blocking Resources

CSS blocks rendering. JavaScript (without async/defer) blocks HTML parsing.

<!-- ❌ Blocking -->
<link rel="stylesheet" href="/styles/all.css">
<script src="/app.js"></script>

<!-- ✅ Non-blocking -->
<style>/* Critical CSS inline */</style>
<link rel="preload" href="/styles/below-fold.css" as="style"
      onload="this.rel='stylesheet'">
<script src="/app.js" defer></script>

Extract critical CSS (above-the-fold styles) with tools like critical:

npx critical https://example.com --inline --minify > index.html

4. Avoid Client-Side Rendering for LCP Content

If your LCP element requires JavaScript to render, you're adding unnecessary delay:

// ❌ LCP content rendered client-side
function HeroSection() {
  const [hero, setHero] = useState(null);
  useEffect(() => {
    fetch('/api/hero').then(r => r.json()).then(setHero);
  }, []);
  return hero ? <img src={hero.image} /> : null;
}

// ✅ LCP content server-rendered
// In Next.js, Astro, or similar frameworks
export async function getStaticProps() {
  const hero = await fetchHero();
  return { props: { hero } };
}
function HeroSection({ hero }) {
  return <img src={hero.image} />;
}

Use SSR or SSG for pages where LCP matters (most pages). See the Next.js or Astro articles for implementation details.

5. Optimize Image Size and Format

A 2MB JPEG hero image will kill your LCP score:

<!-- ❌ Unoptimized -->
<img src="/hero.jpg" alt="Hero">

<!-- ✅ Optimized with modern formats and sizing -->
<picture>
  <source srcset="/hero.avif" type="image/avif">
  <source srcset="/hero.webp" type="image/webp">
  <img
    src="/hero.jpg"
    alt="Hero"
    width="1200"
    height="600"
    fetchpriority="high"
    decoding="async"
  >
</picture>

See the Asset Optimization article for image format details.

CLS: Cumulative Layout Shift

CLS measures visual stability—how much the page layout shifts unexpectedly while loading. A score of 0 means nothing moved; a score above 0.25 means the page is "jumpy" and frustrating to use.

How CLS Is Calculated

CLS sums up all individual layout shift scores during the page's lifetime. Each shift is calculated as:

Layout Shift Score = Impact Fraction × Distance Fraction
  • Impact Fraction: Percentage of viewport affected by the shift
  • Distance Fraction: How far elements moved (as percentage of viewport)

A shift affecting 50% of the viewport, moving content by 25% of viewport height = 0.5 × 0.25 = 0.125 CLS

Common CLS Causes and Fixes

1. Images Without Dimensions

When an image loads, the browser doesn't know its size until it downloads. Without explicit dimensions, layout shifts when the image appears.

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

<!-- ✅ No layout shift -->
<img src="/photo.jpg" alt="Photo" width="800" height="600">

For responsive images, use CSS aspect ratio:

.hero-image {
  aspect-ratio: 16 / 9;
  width: 100%;
  height: auto;
}

Or the aspect-ratio attribute in modern browsers:

<img
  src="/photo.jpg"
  alt="Photo"
  style="aspect-ratio: 16/9; width: 100%; height: auto;"
>

2. Ads, Embeds, and Iframes

Third-party content often loads late and shifts content around it.

<!-- ❌ Ad shifts content when it loads -->
<div class="sidebar">
  <div id="ad-slot"></div>
  <nav>...</nav>
</div>

<!-- ✅ Reserve space for the ad -->
<div class="sidebar">
  <div id="ad-slot" style="min-height: 250px;">
    <!-- Ad loads here -->
  </div>
  <nav>...</nav>
</div>

For embeds with unknown sizes, use a container with minimum dimensions:

.embed-container {
  min-height: 400px;
  background: #f0f0f0; /* Visual placeholder */
}

3. Web Fonts Causing FOUT

When custom fonts load, they can cause text to reflow (Flash of Unstyled Text—FOUT), shifting layout.

/* ❌ Default: invisible text, then swap (FOIT + shift) */
@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom.woff2');
}

/* ✅ Optional: Use fallback if font doesn't load quickly */
@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom.woff2');
  font-display: optional;
}

font-display options for CLS:

Value Behavior CLS Impact
swap Show fallback immediately, swap when loaded Causes shift
optional Show fallback, only swap if loaded in ~100ms Minimal shift
fallback Short invisible period, then fallback, limited swap window Moderate shift

For zero CLS, use optional or match your fallback font metrics closely:

/* Size-adjusted fallback to minimize reflow */
@font-face {
  font-family: 'CustomFont Fallback';
  src: local('Arial');
  size-adjust: 105%;
  ascent-override: 95%;
  descent-override: 22%;
  line-gap-override: 0%;
}

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

Tools like Fontaine can generate these metrics automatically.

4. Dynamically Injected Content

Content added after initial render (banners, modals, cookie notices) shifts existing content.

// ❌ Banner shifts page content
function App() {
  const [showBanner, setShowBanner] = useState(false);
  useEffect(() => {
    checkCookieConsent().then(needed => setShowBanner(needed));
  }, []);

  return (
    <>
      {showBanner && <CookieBanner />}  {/* Shifts everything below */}
      <Header />
      <Main />
    </>
  );
}

// ✅ Reserve space or use overlay positioning
function App() {
  return (
    <>
      <div style={{ minHeight: '60px' }}>  {/* Reserved space */}
        {showBanner && <CookieBanner />}
      </div>
      <Header />
      <Main />
    </>
  );
}

// ✅ Or use fixed/overlay positioning (no shift)
.cookie-banner {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
}

5. Animations That Trigger Layout

Animating properties like width, height, top, left, or margin causes layout recalculation and can trigger CLS.

/* ❌ Triggers layout shifts */
.expanding-box {
  transition: height 0.3s;
}

/* ✅ Use transform (compositor-only, no layout) */
.expanding-box {
  transition: transform 0.3s;
}
.expanding-box.expanded {
  transform: scaleY(1.5);
}

The only properties that don't trigger layout are transform and opacity. Animate these exclusively.

Debugging CLS

Use the Layout Shift Regions feature in Chrome DevTools:

  1. DevTools → Rendering (three-dot menu → More tools → Rendering)
  2. Enable "Layout Shift Regions"
  3. Reload the page—blue rectangles highlight shifting elements

Or use the Performance panel to see individual layout shifts in the timeline.

INP: Interaction to Next Paint

INP replaced First Input Delay (FID) as a Core Web Vital in March 2024. While FID only measured the first interaction, INP measures responsiveness throughout the entire page session.

What INP Measures

INP tracks the time from user interaction (click, tap, keypress) to when the browser can paint the next frame showing visual feedback.

INP = Input Delay + Processing Time + Presentation Delay

Where:
- Input Delay: Time until event handler runs (main thread busy)
- Processing Time: Time running the event handler
- Presentation Delay: Time for browser to render result

The reported INP is typically the worst (highest latency) interaction during the session, with statistical adjustments for sessions with many interactions.

Common INP Problems

1. Long Tasks Blocking Main Thread

JavaScript tasks over 50ms are "long tasks" that block the main thread, delaying input response.

// ❌ Long task: blocks main thread for 200ms
function processLargeDataset(data) {
  for (let i = 0; i < data.length; i++) {
    expensiveOperation(data[i]); // Takes 0.2ms each, 1000 items = 200ms
  }
}

// ✅ Chunked: yields to main thread periodically
async function processLargeDataset(data) {
  const CHUNK_SIZE = 50;
  for (let i = 0; i < data.length; i += CHUNK_SIZE) {
    const chunk = data.slice(i, i + CHUNK_SIZE);
    chunk.forEach(item => expensiveOperation(item));

    // Yield to main thread after each chunk
    await scheduler.yield?.() ?? new Promise(r => setTimeout(r, 0));
  }
}

The scheduler.yield() API (Chrome 115+) yields control back to the browser, allowing pending interactions to be processed.

2. Expensive Event Handlers

// ❌ Heavy handler runs synchronously
function SearchBox() {
  const handleInput = (e) => {
    const results = searchDatabase(e.target.value); // 100ms operation
    setResults(results);
  };
  return <input onChange={handleInput} />;
}

// ✅ Debounce and use web worker for heavy computation
function SearchBox() {
  const handleInput = useDebouncedCallback((value) => {
    searchWorker.postMessage(value);
  }, 150);

  useEffect(() => {
    searchWorker.onmessage = (e) => setResults(e.data);
  }, []);

  return <input onChange={(e) => handleInput(e.target.value)} />;
}

3. Hydration Blocking Interactions

In SSR frameworks, the page is visible before JavaScript loads, but buttons don't work until hydration completes.

// ❌ User clicks button, nothing happens (JS still loading)
<button onClick={handleSubmit}>Submit</button>

// ✅ Show loading state until interactive
function SubmitButton({ isHydrated }) {
  return (
    <button
      onClick={handleSubmit}
      disabled={!isHydrated}
      aria-busy={!isHydrated}
    >
      {isHydrated ? 'Submit' : 'Loading...'}
    </button>
  );
}

Or use progressive hydration patterns (available in Astro, Next.js 13+ with React Server Components).

4. Third-Party Scripts

Analytics, chat widgets, and advertising scripts often run expensive code on the main thread.

<!-- ❌ Loads and executes immediately, blocking main thread -->
<script src="https://analytics.example.com/heavy-analytics.js"></script>

<!-- ✅ Load after page is interactive -->
<script>
  window.addEventListener('load', () => {
    const script = document.createElement('script');
    script.src = 'https://analytics.example.com/heavy-analytics.js';
    document.body.appendChild(script);
  });
</script>

Or use requestIdleCallback for lowest priority loading:

requestIdleCallback(() => {
  loadAnalytics();
}, { timeout: 3000 });

Optimizing INP

Use requestAnimationFrame for Visual Updates

Ensure UI updates happen at the right time in the render cycle:

// ❌ May cause jank if called at wrong time
function updateProgress(value) {
  progressBar.style.width = `${value}%`;
}

// ✅ Synchronized with browser paint cycle
function updateProgress(value) {
  requestAnimationFrame(() => {
    progressBar.style.width = `${value}%`;
  });
}

Use CSS content-visibility for Off-Screen Content

Skip rendering work for content not in the viewport:

.comments-section {
  content-visibility: auto;
  contain-intrinsic-size: 0 500px; /* Estimated height */
}

This dramatically reduces main thread work, especially on long pages.

Optimize React/Vue Rendering

// ❌ Entire list re-renders on any state change
function ItemList({ items, onSelect }) {
  return items.map(item => (
    <Item key={item.id} item={item} onSelect={onSelect} />
  ));
}

// ✅ Memoize to prevent unnecessary re-renders
const Item = React.memo(function Item({ item, onSelect }) {
  return (
    <div onClick={() => onSelect(item.id)}>
      {item.name}
    </div>
  );
});

Other Important Metrics

Beyond Core Web Vitals, these metrics provide additional insight:

Time to First Byte (TTFB)

Time from request to first byte of response. Target: <800ms for first visit, <100ms for cached.

// Measure TTFB in JavaScript
const [entry] = performance.getEntriesByType('navigation');
console.log('TTFB:', entry.responseStart - entry.requestStart);

First Contentful Paint (FCP)

When the first text or image appears. Should be <1.8s for "good" score.

Total Blocking Time (TBT)

Lab metric summing all long task time (portion over 50ms) between FCP and Time to Interactive. Correlates with INP but is measurable in lab tests.

Speed Index

Visual completeness over time—how quickly content is painted to the screen. Lower is better; target <3.4s.

Measuring Core Web Vitals

In the Lab (Development)

// Using web-vitals library
import { onLCP, onCLS, onINP } from 'web-vitals';

onLCP(console.log);
onCLS(console.log);
onINP(console.log);

Install with:

pnpm add web-vitals

In the Field (Real Users)

Send metrics to your analytics:

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

function sendToAnalytics(metric) {
  fetch('/api/vitals', {
    method: 'POST',
    body: JSON.stringify(metric),
    keepalive: true, // Survives page unload
  });
}

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

Google's Tools

  • PageSpeed Insights: Lab + field data (CrUX) for any URL
  • Chrome User Experience Report (CrUX): Real-world data from Chrome users
  • Search Console: Core Web Vitals report for your site's URLs
  • Lighthouse: Detailed lab audit with actionable recommendations

See Also

Last updated: March 23, 2026