Performanceintermediate

Core Web Vitals Explained — The Complete Guide

Master Core Web Vitals: LCP, INP, CLS, FCP, and TTFB. Learn how to measure, debug, and optimize every metric with real code examples.

17 min read·Published Apr 7, 2026
performanceweb-vitalslighthousemetrics

What Are Core Web Vitals?

Core Web Vitals are a set of real-world, user-centered metrics that Google uses to quantify the experience of your site. They measure loading performance, interactivity, and visual stability. Google uses these metrics as ranking signals, so they directly affect your SEO.

+-------------------------------------------------------+
|                  Core Web Vitals                       |
|                                                        |
|   +-------------+  +-------------+  +-------------+   |
|   |    LCP      |  |    INP      |  |    CLS      |   |
|   |  Loading    |  | Interactivity|  |  Visual     |   |
|   |  < 2.5s     |  |  < 200ms   |  |  Stability  |   |
|   |             |  |             |  |  < 0.1      |   |
|   +-------------+  +-------------+  +-------------+   |
|                                                        |
|   +-------------+  +-------------+                     |
|   |    FCP      |  |    TTFB     |                     |
|   |  First Paint|  |  Server     |                     |
|   |  < 1.8s     |  |  Response   |                     |
|   |             |  |  < 800ms    |                     |
|   +-------------+  +-------------+                     |
+-------------------------------------------------------+

Three metrics form the "Core" set:

  1. LCP (Largest Contentful Paint) — loading performance
  2. INP (Interaction to Next Paint) — interactivity responsiveness
  3. CLS (Cumulative Layout Shift) — visual stability

Two supplementary metrics are also tracked:

  1. FCP (First Contentful Paint) — perceived load speed
  2. TTFB (Time to First Byte) — server responsiveness

Largest Contentful Paint (LCP)

LCP measures how long it takes for the largest visible content element to render. This is typically a hero image, video thumbnail, or large text block.

Target Thresholds

+------------------+------------------+------------------+
|      Good        |  Needs Improve   |      Poor        |
+------------------+------------------+------------------+
|    <= 2.5s       |   2.5s - 4.0s    |     > 4.0s       |
+------------------+------------------+------------------+

What Counts as the LCP Element?

The browser considers these element types:

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

Measuring LCP

// Using the PerformanceObserver API
const observer = new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const lastEntry = entries[entries.length - 1];

  console.log('LCP:', lastEntry.startTime, 'ms');
  console.log('Element:', lastEntry.element);
});

observer.observe({ type: 'largest-contentful-paint', buffered: true });

Common LCP Problems and Fixes

Problem 1: Slow server response

// Bad: SSR with blocking database queries
export async function getServerSideProps() {
  const data = await db.query('SELECT * FROM products'); // 2s query
  return { props: { data } };
}

// Good: Use ISR (Incremental Static Regeneration)
export async function getStaticProps() {
  const data = await db.query('SELECT * FROM products');
  return {
    props: { data },
    revalidate: 60, // Regenerate every 60 seconds
  };
}

Problem 2: Render-blocking resources

<!-- Bad: Blocking CSS in head -->
<link rel="stylesheet" href="/styles/non-critical.css" />

<!-- Good: Preload critical CSS, defer the rest -->
<link rel="preload" href="/styles/critical.css" as="style" />
<link rel="stylesheet" href="/styles/critical.css" />
<link
  rel="stylesheet"
  href="/styles/non-critical.css"
  media="print"
  onload="this.media='all'"
/>

Problem 3: Unoptimized hero images

// Bad: Large unoptimized image
<img src="/hero-4000x2000.jpg" alt="Hero" />

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

<Image
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={600}
  priority          // Preloads the LCP image
  sizes="100vw"
  quality={85}
/>

Problem 4: Client-side rendering delays

// Bad: Fetching LCP content on the client
function HeroSection() {
  const [hero, setHero] = useState(null);

  useEffect(() => {
    fetch('/api/hero').then(r => r.json()).then(setHero);
  }, []);

  if (!hero) return <Skeleton />;
  return <h1>{hero.title}</h1>;
}

// Good: Server-side render the LCP content
export async function getStaticProps() {
  const hero = await fetchHero();
  return { props: { hero } };
}

function HeroSection({ hero }) {
  return <h1>{hero.title}</h1>; // Available on first paint
}

LCP Optimization Checklist

LCP Budget Breakdown (target: 2.5s total)
+-----------------------+----------+
| Phase                 | Budget   |
+-----------------------+----------+
| TTFB                  | 800ms    |
| Resource load delay   | 400ms    |
| Resource load time    | 800ms    |
| Element render delay  | 500ms    |
+-----------------------+----------+
| Total                 | 2500ms   |
+-----------------------+----------+

Interaction to Next Paint (INP)

INP replaced FID (First Input Delay) in March 2024. While FID only measured the delay of the first interaction, INP tracks the responsiveness of all interactions throughout the page lifecycle.

Target Thresholds

+------------------+------------------+------------------+
|      Good        |  Needs Improve   |      Poor        |
+------------------+------------------+------------------+
|    <= 200ms      |  200ms - 500ms   |     > 500ms      |
+------------------+------------------+------------------+

What INP Measures

INP measures the time from when a user interacts (click, tap, keypress) to when the browser paints the next frame reflecting that interaction.

User clicks button
       |
       v
+------+------+--------+------+
| Input Delay  | Process | Paint |
| (queued JS)  | (handler)| (render)|
+------+------+--------+------+
|<------------- INP ------------->|

Measuring INP

// Using the web-vitals library
import { onINP } from 'web-vitals';

onINP((metric) => {
  console.log('INP:', metric.value, 'ms');
  console.log('Entries:', metric.entries);

  // Each entry shows which interaction caused the delay
  metric.entries.forEach((entry) => {
    console.log('Target:', entry.target);
    console.log('Duration:', entry.duration);
  });
});

Common INP Problems and Fixes

Problem 1: Long-running event handlers

// Bad: Heavy computation in click handler blocks the main thread
button.addEventListener('click', () => {
  const result = heavyComputation(data); // 300ms blocking
  updateUI(result);
});

// Good: Break work into smaller chunks with scheduler
button.addEventListener('click', async () => {
  // Show immediate feedback
  button.textContent = 'Processing...';

  // Yield to the browser between chunks
  const result = await yieldingComputation(data);
  updateUI(result);
});

async function yieldingComputation(data) {
  const chunks = splitIntoChunks(data, 1000);
  let result = [];

  for (const chunk of chunks) {
    result.push(...processChunk(chunk));
    // Yield control back to the browser
    await new Promise((resolve) => setTimeout(resolve, 0));
  }

  return result;
}

Problem 2: Expensive React re-renders

// Bad: Unoptimized list that re-renders everything
function ProductList({ products, onSelect }) {
  return (
    <ul>
      {products.map((p) => (
        <li key={p.id} onClick={() => onSelect(p)}>
          <ExpensiveProductCard product={p} />
        </li>
      ))}
    </ul>
  );
}

// Good: Memoize individual items
const ProductCard = React.memo(function ProductCard({ product, onSelect }) {
  return (
    <li onClick={() => onSelect(product)}>
      <h3>{product.name}</h3>
      <p>{product.price}</p>
    </li>
  );
});

function ProductList({ products, onSelect }) {
  const handleSelect = useCallback((product) => {
    onSelect(product);
  }, [onSelect]);

  return (
    <ul>
      {products.map((p) => (
        <ProductCard key={p.id} product={p} onSelect={handleSelect} />
      ))}
    </ul>
  );
}

Problem 3: Layout thrashing

// Bad: Forces multiple layout recalculations
elements.forEach((el) => {
  const height = el.offsetHeight; // Read — triggers layout
  el.style.height = height * 2 + 'px'; // Write — invalidates layout
});

// Good: Batch reads, then batch writes
const heights = elements.map((el) => el.offsetHeight); // All reads

elements.forEach((el, i) => {
  el.style.height = heights[i] * 2 + 'px'; // All writes
});

Cumulative Layout Shift (CLS)

CLS measures visual stability. It quantifies how much page content shifts around unexpectedly during the entire page lifecycle. Nothing frustrates users more than clicking a button only to have the page shift and accidentally clicking an ad instead.

Target Thresholds

+------------------+------------------+------------------+
|      Good        |  Needs Improve   |      Poor        |
+------------------+------------------+------------------+
|    <= 0.1        |   0.1 - 0.25     |     > 0.25       |
+------------------+------------------+------------------+

How CLS Is Calculated

CLS = Sum of (impact fraction * distance fraction) for each layout shift

Impact Fraction:  % of viewport affected by the shift
Distance Fraction: distance the element moved / viewport height

Example:
  An element covers 50% of the viewport and shifts down by 25%
  CLS contribution = 0.50 * 0.25 = 0.125

Measuring CLS

// PerformanceObserver for layout shifts
let clsValue = 0;
let sessionValue = 0;
let sessionEntries = [];

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // Only count shifts without user input
    if (!entry.hadRecentInput) {
      sessionEntries.push(entry);
      sessionValue += entry.value;
    }
  }
  clsValue = Math.max(clsValue, sessionValue);
  console.log('Current CLS:', clsValue);
});

observer.observe({ type: 'layout-shift', buffered: true });

Common CLS Problems and Fixes

Problem 1: Images without dimensions

<!-- Bad: No dimensions — causes layout shift when image loads -->
<img src="/photo.jpg" alt="Photo" />

<!-- Good: Always set width and height -->
<img src="/photo.jpg" alt="Photo" width="800" height="600" />

<!-- Good: CSS aspect ratio -->
<style>
.image-container {
  aspect-ratio: 16 / 9;
  width: 100%;
}
</style>
<div class="image-container">
  <img src="/photo.jpg" alt="Photo" style="width:100%; height:100%; object-fit:cover;" />
</div>

Problem 2: Dynamically injected content

// Bad: Ad loads and pushes content down
function Article() {
  return (
    <div>
      <h1>Article Title</h1>
      <AdBanner />           {/* Height unknown until ad loads */}
      <p>Article content...</p>
    </div>
  );
}

// Good: Reserve space for dynamic content
function Article() {
  return (
    <div>
      <h1>Article Title</h1>
      <div style={{ minHeight: '250px' }}>  {/* Reserved space */}
        <AdBanner />
      </div>
      <p>Article content...</p>
    </div>
  );
}

Problem 3: Web fonts causing text reflow

/* Bad: Font swap causes visible text reflow */
@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom.woff2') format('woff2');
}

/* Good: Use font-display and size-adjust */
@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom.woff2') format('woff2');
  font-display: optional;    /* Prevents layout shift from font swap */
  size-adjust: 105%;         /* Match fallback font metrics */
  ascent-override: 95%;
  descent-override: 20%;
  line-gap-override: 0%;
}

Problem 4: Dynamic content insertion above the viewport

// Bad: Inserting notification banner pushes everything down
function showBanner(message) {
  const banner = document.createElement('div');
  banner.textContent = message;
  document.body.prepend(banner); // Shifts entire page
}

// Good: Use CSS transform instead of affecting layout
function showBanner(message) {
  const banner = document.createElement('div');
  banner.textContent = message;
  banner.style.cssText = `
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    z-index: 1000;
    transform: translateY(-100%);
    transition: transform 0.3s ease;
  `;
  document.body.appendChild(banner);
  requestAnimationFrame(() => {
    banner.style.transform = 'translateY(0)';
  });
}

First Contentful Paint (FCP)

FCP marks the time from navigation to when the browser renders the first bit of content from the DOM. This could be text, an image, an SVG, or a canvas element.

Target Thresholds

+------------------+------------------+------------------+
|      Good        |  Needs Improve   |      Poor        |
+------------------+------------------+------------------+
|    <= 1.8s       |   1.8s - 3.0s    |     > 3.0s       |
+------------------+------------------+------------------+

FCP vs LCP

Timeline:
|--TTFB--|--FCP--|--LCP--|
          ^       ^
          |       |
          |       Largest element paints (hero image, main heading)
          |
          First pixel of content appears (loading spinner, nav bar)

FCP answers "Is it loading?" while LCP answers "Is the main content ready?"

Measuring FCP

// Using PerformanceObserver
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.name === 'first-contentful-paint') {
      console.log('FCP:', entry.startTime, 'ms');
    }
  }
});

observer.observe({ type: 'paint', buffered: true });

// Or using the web-vitals library
import { onFCP } from 'web-vitals';

onFCP((metric) => {
  console.log('FCP:', metric.value);
});

Optimizing FCP

<!-- 1. Inline critical CSS for above-the-fold content -->
<head>
  <style>
    /* Only styles needed for first render */
    body { margin: 0; font-family: system-ui; }
    .header { height: 64px; background: #1a1a2e; }
    .hero { padding: 2rem; }
  </style>

  <!-- 2. Preconnect to critical third-party origins -->
  <link rel="preconnect" href="https://fonts.googleapis.com" />
  <link rel="preconnect" href="https://cdn.example.com" crossorigin />

  <!-- 3. Preload critical resources -->
  <link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin />
</head>
// 4. Avoid render-blocking JavaScript
// Bad
<script src="/analytics.js"></script>

// Good
<script src="/analytics.js" defer></script>

Time to First Byte (TTFB)

TTFB measures the time between the browser requesting a page and receiving the first byte of the response from the server. It captures DNS lookup, TCP connection, TLS negotiation, and server processing time.

Target Thresholds

+------------------+------------------+------------------+
|      Good        |  Needs Improve   |      Poor        |
+------------------+------------------+------------------+
|    <= 800ms      |  800ms - 1800ms  |     > 1800ms     |
+------------------+------------------+------------------+

TTFB Breakdown

Browser                          Server
   |                                |
   |------- DNS Lookup ----------->|
   |                                |
   |------- TCP Connection ------->|
   |                                |
   |------- TLS Handshake -------->|
   |                                |
   |------- HTTP Request --------->|
   |                                |
   |          [ Server Processing ] |
   |                                |
   |<------ First Byte ------------|
   |                                |
   |<-------- TTFB -------------->|

Measuring TTFB

// Using Navigation Timing API
const [navigation] = performance.getEntriesByType('navigation');

const ttfb = navigation.responseStart - navigation.requestStart;
console.log('TTFB:', ttfb, 'ms');

// Breakdown
console.log('DNS:', navigation.domainLookupEnd - navigation.domainLookupStart);
console.log('TCP:', navigation.connectEnd - navigation.connectStart);
console.log('TLS:', navigation.requestStart - navigation.secureConnectionStart);
console.log('Server:', navigation.responseStart - navigation.requestStart);

Optimizing TTFB

// 1. Use a CDN — serve from edge locations near users

// 2. Enable server-side caching
// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, s-maxage=60, stale-while-revalidate=300',
          },
        ],
      },
    ];
  },
};

// 3. Avoid blocking database queries
// Bad: Sequential queries
const user = await db.getUser(id);
const posts = await db.getPosts(user.id);
const comments = await db.getComments(posts.map(p => p.id));

// Good: Parallel queries
const [user, posts] = await Promise.all([
  db.getUser(id),
  db.getPostsByUserId(id),
]);
const comments = await db.getComments(posts.map(p => p.id));

Measuring All Vitals with the web-vitals Library

Google's web-vitals library is the standard way to measure all Core Web Vitals in production.

Installation and Setup

npm install web-vitals
// lib/vitals.js
import { onCLS, onFCP, onINP, onLCP, onTTFB } from 'web-vitals';

function sendToAnalytics(metric) {
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,    // 'good', 'needs-improvement', 'poor'
    delta: metric.delta,
    id: metric.id,
    navigationType: metric.navigationType,
  });

  // Use sendBeacon for reliability during page unload
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/api/vitals', body);
  } else {
    fetch('/api/vitals', { body, method: 'POST', keepalive: true });
  }
}

export function reportWebVitals() {
  onCLS(sendToAnalytics);
  onFCP(sendToAnalytics);
  onINP(sendToAnalytics);
  onLCP(sendToAnalytics);
  onTTFB(sendToAnalytics);
}

Next.js Integration

// pages/_app.tsx
import { reportWebVitals } from '../lib/vitals';

export function reportWebVitals(metric) {
  switch (metric.name) {
    case 'LCP':
      console.log('LCP:', metric.value);
      break;
    case 'INP':
      console.log('INP:', metric.value);
      break;
    case 'CLS':
      console.log('CLS:', metric.value);
      break;
    case 'FCP':
      console.log('FCP:', metric.value);
      break;
    case 'TTFB':
      console.log('TTFB:', metric.value);
      break;
  }
}

export default function App({ Component, pageProps }) {
  return <Component {...pageProps} />;
}

Custom Reporting Dashboard

// api/vitals.js — Collect metrics server-side
export default async function handler(req, res) {
  if (req.method !== 'POST') return res.status(405).end();

  const metric = req.body;

  // Store in your analytics database
  await db.insert('web_vitals', {
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    page: req.headers.referer,
    userAgent: req.headers['user-agent'],
    timestamp: new Date(),
  });

  res.status(200).end();
}

Tools for Measuring Web Vitals

Lighthouse (Lab Data)

Lighthouse provides lab data — simulated metrics under controlled conditions.

# CLI usage
npm install -g lighthouse
lighthouse https://example.com --output html --output-path report.html

# With specific device emulation
lighthouse https://example.com \
  --emulated-form-factor=mobile \
  --throttling.cpuSlowdownMultiplier=4
// Programmatic Lighthouse in Node.js
import lighthouse from 'lighthouse';
import * as chromeLauncher from 'chrome-launcher';

async function runLighthouse(url) {
  const chrome = await chromeLauncher.launch({ chromeFlags: ['--headless'] });

  const result = await lighthouse(url, {
    port: chrome.port,
    onlyCategories: ['performance'],
  });

  const { lhr } = result;
  console.log('Performance Score:', lhr.categories.performance.score * 100);
  console.log('LCP:', lhr.audits['largest-contentful-paint'].numericValue);
  console.log('CLS:', lhr.audits['cumulative-layout-shift'].numericValue);
  console.log('TBT:', lhr.audits['total-blocking-time'].numericValue);

  await chrome.kill();
  return lhr;
}

Lab Data vs Field Data

+-------------------+---------------------------+---------------------------+
|                   |     Lab Data              |     Field Data (RUM)      |
+-------------------+---------------------------+---------------------------+
| Source            | Simulated (Lighthouse)    | Real users (CrUX, RUM)   |
| Environment       | Controlled, consistent    | Varies per user/device    |
| Metrics           | LCP, CLS, TBT, FCP, SI   | LCP, CLS, INP, FCP, TTFB |
| INP available?    | No (uses TBT instead)     | Yes                       |
| When to use       | Development, CI/CD        | Production monitoring     |
| Limitation        | Doesn't reflect real users| Needs traffic volume      |
+-------------------+---------------------------+---------------------------+

PageSpeed Insights

PageSpeed Insights combines both lab and field data.

// Programmatic access via API
const API_KEY = process.env.PAGESPEED_API_KEY;
const url = 'https://example.com';

const response = await fetch(
  `https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=${url}&key=${API_KEY}&strategy=mobile`
);
const data = await response.json();

// Field data (Chrome UX Report)
const cruxMetrics = data.loadingExperience.metrics;
console.log('LCP (p75):', cruxMetrics.LARGEST_CONTENTFUL_PAINT_MS.percentile);
console.log('INP (p75):', cruxMetrics.INTERACTION_TO_NEXT_PAINT.percentile);
console.log('CLS (p75):', cruxMetrics.CUMULATIVE_LAYOUT_SHIFT_SCORE.percentile);

// Lab data (Lighthouse)
const labAudits = data.lighthouseResult.audits;
console.log('Lab LCP:', labAudits['largest-contentful-paint'].displayValue);

Real User Monitoring (RUM)

Lab tools give you a baseline, but RUM tells you what your actual users experience. The 75th percentile (p75) is what Google uses for ranking.

Why p75?

Users sorted by metric value:
|============================|=======|
0%                          75%    100%

p75 means: 75% of users have a better experience than this value.
Google uses p75 because:
- Median (p50) ignores struggling users
- p95 is skewed by extreme outliers
- p75 balances between representing most users and catching problems

Building a RUM Pipeline

// Step 1: Collect metrics on the client
import { onCLS, onINP, onLCP } from 'web-vitals';

const queue = [];

function addToQueue(metric) {
  queue.push({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    path: window.location.pathname,
    connection: navigator.connection?.effectiveType,
    deviceMemory: navigator.deviceMemory,
    timestamp: Date.now(),
  });
}

onCLS(addToQueue);
onINP(addToQueue);
onLCP(addToQueue);

// Step 2: Flush on visibility change (user leaves tab)
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden' && queue.length > 0) {
    navigator.sendBeacon('/api/rum', JSON.stringify(queue));
    queue.length = 0;
  }
});
// Step 3: Aggregate and analyze server-side
function analyzeMetrics(metrics) {
  const grouped = {};

  for (const m of metrics) {
    if (!grouped[m.name]) grouped[m.name] = [];
    grouped[m.name].push(m.value);
  }

  for (const [name, values] of Object.entries(grouped)) {
    values.sort((a, b) => a - b);
    const p50 = values[Math.floor(values.length * 0.5)];
    const p75 = values[Math.floor(values.length * 0.75)];
    const p95 = values[Math.floor(values.length * 0.95)];

    console.log(`${name}: p50=${p50} p75=${p75} p95=${p95}`);
  }
}

Debugging Poor Metrics

Identifying the LCP Element

new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const lcpEntry = entries[entries.length - 1];

  // Log the actual element causing slow LCP
  console.log('LCP element:', lcpEntry.element);
  console.log('LCP tag:', lcpEntry.element?.tagName);
  console.log('LCP src:', lcpEntry.element?.src || lcpEntry.element?.currentSrc);
  console.log('LCP time:', lcpEntry.startTime);
  console.log('LCP size:', lcpEntry.size);
}).observe({ type: 'largest-contentful-paint', buffered: true });

Finding Layout Shift Sources

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.hadRecentInput) continue;

    console.log('Layout shift:', entry.value);
    for (const source of entry.sources) {
      console.log('  Element:', source.node);
      console.log('  Previous rect:', source.previousRect);
      console.log('  Current rect:', source.currentRect);
    }
  }
}).observe({ type: 'layout-shift', buffered: true });

Profiling Long Tasks (INP Debugging)

// Find tasks that block the main thread for > 50ms
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // Long tasks are > 50ms
    console.log('Long task:', entry.duration, 'ms');
    console.log('  Start:', entry.startTime);
    console.log('  Attribution:', entry.attribution);
  }
}).observe({ type: 'longtask', buffered: true });

Complete Metric Summary Table

+--------+-------------------------------+----------+-----------+-----------+
| Metric | Measures                      | Good     | Improve   | Poor      |
+--------+-------------------------------+----------+-----------+-----------+
| LCP    | Loading (largest element)     | <= 2.5s  | 2.5-4.0s  | > 4.0s    |
| INP    | Interactivity (all clicks)    | <= 200ms | 200-500ms | > 500ms   |
| CLS    | Visual stability (shifts)     | <= 0.1   | 0.1-0.25  | > 0.25    |
| FCP    | First paint (any content)     | <= 1.8s  | 1.8-3.0s  | > 3.0s    |
| TTFB   | Server response (first byte)  | <= 800ms | 0.8-1.8s  | > 1.8s    |
+--------+-------------------------------+----------+-----------+-----------+

Putting It All Together — A Next.js Example

// pages/_app.tsx — Full Web Vitals integration
import type { AppProps } from 'next/app';
import { useEffect } from 'react';
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals';

function sendMetric(metric) {
  const url = '/api/vitals';
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    id: metric.id,
    page: window.location.pathname,
  });

  if (navigator.sendBeacon) {
    navigator.sendBeacon(url, body);
  }
}

export default function App({ Component, pageProps }: AppProps) {
  useEffect(() => {
    onCLS(sendMetric);
    onINP(sendMetric);
    onLCP(sendMetric);
    onFCP(sendMetric);
    onTTFB(sendMetric);
  }, []);

  return <Component {...pageProps} />;
}
// components/OptimizedHero.tsx — LCP-optimized hero section
import Image from 'next/image';

export function OptimizedHero({ title, subtitle, imageSrc }) {
  return (
    <section>
      {/* Text renders immediately — no layout shift */}
      <h1>{title}</h1>
      <p>{subtitle}</p>

      {/* Priority flag preloads LCP image */}
      <Image
        src={imageSrc}
        alt={title}
        width={1200}
        height={600}
        priority
        sizes="(max-width: 768px) 100vw, 1200px"
        style={{ width: '100%', height: 'auto' }}
      />
    </section>
  );
}

Key Takeaways

  • Core Web Vitals (LCP, INP, CLS) directly affect Google search rankings
  • LCP targets loading speed of the main content element — aim for under 2.5 seconds
  • INP tracks responsiveness for every user interaction — aim for under 200 milliseconds
  • CLS measures unexpected layout shifts — aim for a score below 0.1
  • Lab data (Lighthouse) is for development; field data (RUM) is for production
  • Google uses the 75th percentile of field data for ranking decisions
  • The web-vitals library is the standard measurement tool
  • Preload LCP images, reserve space for dynamic content, and break up long tasks
  • Monitor metrics continuously — performance is not a one-time fix

Found this helpful?

Support devsofus — help us keep creating free dev guides.

Related Articles