React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

core-web-vitalslcpinpclsttfbfcplighthousepagespeedweb-vitalsperformance

Core Web Vitals Optimization — Hit LCP, INP, and CLS targets for real-world performance

Recipe

// Install the web-vitals library for real user measurement
// npm install web-vitals
 
// app/components/WebVitals.tsx
"use client";
 
import { useReportWebVitals } from "next/web-vitals";
 
export function WebVitals() {
  useReportWebVitals((metric) => {
    const { name, value, rating } = metric;
    // Send to your analytics endpoint
    fetch("/api/vitals", {
      method: "POST",
      body: JSON.stringify({ name, value, rating }),
    });
  });
  return null;
}
 
// Targets:
// LCP (Largest Contentful Paint): under 2.5s
// INP (Interaction to Next Paint): under 200ms
// CLS (Cumulative Layout Shift): under 0.1
// TTFB (Time to First Byte): under 800ms
// FCP (First Contentful Paint): under 1.8s

When to reach for this: Before and after every production deployment. Core Web Vitals are Google ranking factors and directly correlate with user engagement and conversion rates.

Working Example

// ---- BEFORE: Poor vitals — LCP 4.1s, INP 380ms, CLS 0.28 ----
 
// app/page.tsx — Client component with blocking resources
"use client";
 
import { useState, useEffect } from "react";
import { motion } from "framer-motion"; // 44KB blocking import
 
export default function HomePage() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(true);
 
  // Fetch after hydration — delays LCP
  useEffect(() => {
    fetch("/api/products")
      .then((r) => r.json())
      .then((data) => {
        setProducts(data);
        setLoading(false);
      });
  }, []);
 
  // INP problem: heavy synchronous filtering on every click
  const handleFilter = (category: string) => {
    const sorted = products
      .filter((p) => p.category === category)
      .sort((a, b) => a.name.localeCompare(b.name))
      .map((p) => ({ ...p, score: computeExpensiveScore(p) })); // 200ms+ sync
    setProducts(sorted);
  };
 
  if (loading) return <div>Loading...</div>;
 
  return (
    <div>
      {/* CLS: Image with no dimensions */}
      <img src="/hero.jpg" alt="Hero" />
 
      {/* CLS: External font causes text reflow */}
      <h1 style={{ fontFamily: "'Fancy Font', serif" }}>Our Products</h1>
 
      {/* CLS: Dynamic content pushes layout */}
      {products.length > 0 && (
        <p className="text-sm text-gray-500">{products.length} products found</p>
      )}
 
      <div className="flex gap-2 my-4">
        {categories.map((cat) => (
          <button key={cat} onClick={() => handleFilter(cat)}>
            {cat}
          </button>
        ))}
      </div>
 
      <motion.div className="grid grid-cols-3 gap-4">
        {products.map((p) => (
          <div key={p.id}>
            <img src={p.image} alt={p.name} /> {/* No lazy loading, no optimization */}
            <p>{p.name} — ${p.price}</p>
          </div>
        ))}
      </motion.div>
    </div>
  );
}
 
// ---- AFTER: Good vitals — LCP 1.8s, INP 85ms, CLS 0.03 ----
 
// app/page.tsx — Server Component with optimized resources
import Image from "next/image";
import { Inter } from "next/font/google";
import { Suspense } from "react";
import { db } from "@/lib/db";
import { ProductGrid } from "./ProductGrid";
import heroImage from "@/public/hero.jpg";
 
const inter = Inter({ subsets: ["latin"], display: "swap" });
 
export default async function HomePage() {
  // LCP fix: Server-side fetch — data available on first paint
  const products = await db.product.findMany({
    orderBy: { name: "asc" },
    take: 50,
  });
 
  return (
    <div className={inter.className}>
      {/* LCP fix: Optimized image with priority preload */}
      <Image
        src={heroImage}
        alt="Product showcase featuring our latest collection"
        priority
        placeholder="blur"
        sizes="100vw"
        className="w-full h-auto"
      />
 
      {/* CLS fix: Self-hosted font via next/font — zero layout shift */}
      <h1 className="text-4xl font-bold mt-8">Our Products</h1>
 
      {/* CLS fix: Reserved space for product count */}
      <p className="text-sm text-gray-500 h-6">
        {products.length} products found
      </p>
 
      <Suspense fallback={<div className="grid grid-cols-3 gap-4 h-96" />}>
        <ProductGrid initialProducts={products} />
      </Suspense>
    </div>
  );
}
 
// ProductGrid.tsx — Client component with optimized interactions
"use client";
 
import { useState, useTransition } from "react";
import Image from "next/image";
 
export function ProductGrid({ initialProducts }: { initialProducts: Product[] }) {
  const [products, setProducts] = useState(initialProducts);
  const [isPending, startTransition] = useTransition();
 
  // INP fix: useTransition marks filtering as non-urgent
  const handleFilter = (category: string) => {
    startTransition(() => {
      const filtered = initialProducts
        .filter((p) => category === "all" || p.category === category)
        .sort((a, b) => a.name.localeCompare(b.name));
      setProducts(filtered);
    });
  };
 
  return (
    <div>
      <div className="flex gap-2 my-4">
        <button onClick={() => handleFilter("all")}>All</button>
        {categories.map((cat) => (
          <button key={cat} onClick={() => handleFilter(cat)}>
            {cat}
          </button>
        ))}
      </div>
 
      <div
        className={`grid grid-cols-3 gap-4 ${isPending ? "opacity-60" : ""}`}
      >
        {products.map((p, index) => (
          <div key={p.id}>
            {/* CLS fix: width + height prevent layout shift */}
            {/* LCP: priority on first 3 images */}
            <Image
              src={p.image}
              alt={p.name}
              width={400}
              height={300}
              priority={index < 3}
              sizes="(max-width: 768px) 100vw, 33vw"
              className="rounded-lg"
            />
            <p className="mt-2 font-medium">{p.name} — ${p.price}</p>
          </div>
        ))}
      </div>
    </div>
  );
}

What this demonstrates:

  • LCP: Server-side data + priority image + preload = 4.1s to 1.8s (56% faster)
  • INP: useTransition defers heavy filtering = 380ms to 85ms (78% faster)
  • CLS: next/image dimensions + next/font + reserved space = 0.28 to 0.03 (89% reduction)
  • TTFB: Server Component rendering streams HTML immediately
  • Removed framer-motion import: 44KB savings from initial bundle

Deep Dive

How It Works

  • LCP (Largest Contentful Paint) measures when the largest visible element (image, heading, video) finishes rendering. The target is under 2.5 seconds. Key factors: server response time, resource load time, render-blocking resources, client-side rendering delay.
  • INP (Interaction to Next Paint) measures the latency from user input (click, tap, keypress) to the next visual update. The target is under 200ms. Key factors: long JavaScript tasks blocking the main thread, heavy synchronous computations, large DOM size.
  • CLS (Cumulative Layout Shift) measures unexpected visual movement of page content. The target is under 0.1. Key factors: images without dimensions, dynamic content injection, fonts causing text reflow, ads or embeds loading asynchronously.
  • TTFB (Time to First Byte) measures how long the browser waits for the first byte of the response. The target is under 800ms. Key factors: server processing time, database queries, network latency, CDN configuration.
  • FCP (First Contentful Paint) measures when the first text or image is painted. The target is under 1.8 seconds. Key factors: render-blocking CSS/JS, font loading, server response time.

Variations

Measuring with the web-vitals library:

// app/vitals.ts
import { onCLS, onINP, onLCP, onFCP, onTTFB } from "web-vitals";
 
function sendToAnalytics(metric: { name: string; value: number; rating: string }) {
  const body = JSON.stringify(metric);
  // Use sendBeacon for reliability during page unload
  if (navigator.sendBeacon) {
    navigator.sendBeacon("/api/vitals", body);
  } else {
    fetch("/api/vitals", { method: "POST", body, keepalive: true });
  }
}
 
export function reportWebVitals() {
  onCLS(sendToAnalytics);
  onINP(sendToAnalytics);
  onLCP(sendToAnalytics);
  onFCP(sendToAnalytics);
  onTTFB(sendToAnalytics);
}

INP optimization with useTransition and useDeferredValue:

"use client";
 
import { useState, useTransition, useDeferredValue } from "react";
 
function SearchableList({ items }: { items: Item[] }) {
  const [query, setQuery] = useState("");
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;
 
  // Filtering deferred — typing stays responsive
  const filtered = items.filter((item) =>
    item.name.toLowerCase().includes(deferredQuery.toLowerCase())
  );
 
  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />
      <div className={isStale ? "opacity-50" : ""}>
        {filtered.map((item) => (
          <div key={item.id}>{item.name}</div>
        ))}
      </div>
    </div>
  );
}

CLS prevention checklist:

// 1. Always set image dimensions
<Image src={url} alt="" width={800} height={600} />
 
// 2. Reserve space for dynamic content
<div className="min-h-[200px]">
  {data ? <Content data={data} /> : <Skeleton />}
</div>
 
// 3. Use next/font instead of external stylesheets
import { Inter } from "next/font/google";
 
// 4. Avoid inserting content above existing content
// BAD: Banner appears above the fold after load
// GOOD: Reserve space for the banner from the start
 
// 5. Use CSS contain for ads and embeds
<div style={{ contain: "layout", minHeight: 250 }}>
  <AdSlot />
</div>

TypeScript Notes

  • The web-vitals library provides TypeScript types for all metric objects: CLSMetric, INPMetric, LCPMetric, etc.
  • useReportWebVitals from next/web-vitals accepts a callback with NextWebVitalsMetric type.
  • The rating field is typed as "good" | "needs-improvement" | "poor".

Gotchas

  • Measuring in development instead of production — Development mode adds React DevTools overhead, Strict Mode double-renders, and hot module replacement delays. Vitals measured in dev are 2-5x worse than production. Fix: Always measure in production builds: npm run build && npm start, or use Lighthouse with production URLs.

  • Lab data vs field data — Lighthouse scores (lab data) may differ significantly from real user metrics (field data) due to network conditions, device capabilities, and geographic distribution. Fix: Use both: Lighthouse for debugging, Chrome UX Report (CrUX) or web-vitals for real user data.

  • LCP element is not what you expect — The LCP element may be a background image, a video poster, or a text block — not always the hero image. Fix: Open Lighthouse, expand the LCP diagnostic, and check which element is identified. Optimize that specific element.

  • INP is not just first click — INP measures the worst interaction across the entire page session (at the 98th percentile). A fast first click but slow filter operation still produces a poor INP score. Fix: Profile all interactive paths, not just the initial page load.

  • CLS measured across the full session — CLS accumulates over the entire page session, not just initial load. A popup that appears 30 seconds after load still contributes to CLS. Fix: Reserve space for all dynamic content, including modals, toasts, banners, and lazy-loaded sections.

  • Caching masks TTFB problems — Repeat visits with cached resources show excellent TTFB, hiding problems that first-time visitors experience. Fix: Test with cache disabled (DevTools Network -> Disable cache) and from different geographic locations.

Alternatives

ApproachTrade-off
LighthouseLab testing; repeatable, does not reflect real users
PageSpeed InsightsUses CrUX field data; limited to pages with enough traffic
web-vitals libraryReal user measurement; requires analytics infrastructure
Chrome UX Report (CrUX)Field data from Chrome users; 28-day rolling window
Vercel Speed InsightsAutomatic for Vercel deployments; vendor-specific
New Relic or Datadog RUMEnterprise monitoring; cost, complex setup

FAQs

What are the three Core Web Vitals and their target thresholds?
  • LCP (Largest Contentful Paint): under 2.5 seconds
  • INP (Interaction to Next Paint): under 200 milliseconds
  • CLS (Cumulative Layout Shift): under 0.1
Why does fetching data in a client-side useEffect hurt LCP?
  • The browser must download, parse, and execute the JS bundle before useEffect runs.
  • The fetch only starts after hydration, adding a full round trip before the largest element can render.
  • Fix: Fetch data in a Server Component so the HTML includes the content on first paint.
How does useTransition improve INP for heavy interactions like filtering?
  • useTransition marks the state update as non-urgent, so React keeps the UI responsive to user input.
  • The browser can paint the next frame before the expensive re-render completes.
  • Users see a pending state (e.g., isPending opacity) instead of a frozen UI.
What causes CLS from images, and how does next/image fix it?
  • Images without explicit width and height cause layout shift because the browser cannot reserve space until the image loads.
  • next/image requires dimensions, automatically reserves the correct aspect ratio, and prevents shift.
Why should you use navigator.sendBeacon instead of fetch for reporting vitals?
  • sendBeacon is reliable during page unload (e.g., when the user navigates away).
  • A regular fetch may be cancelled by the browser when the page is torn down.
  • Use fetch with keepalive: true as a fallback when sendBeacon is unavailable.
Gotcha: Why are Core Web Vitals measured in development mode unreliable?
  • Dev mode adds React DevTools overhead, Strict Mode double-renders, and HMR delays.
  • Vitals in dev are typically 2-5x worse than production.
  • Fix: Always measure with a production build: npm run build && npm start.
What is the difference between lab data and field data for vitals?
  • Lab data (Lighthouse) runs in a controlled environment; repeatable but does not reflect real users.
  • Field data (CrUX, web-vitals) comes from actual user sessions with varying devices and networks.
  • Use both: lab for debugging, field for real-world performance tracking.
Gotcha: Why might your CLS score be poor even if initial load looks stable?
  • CLS accumulates over the entire page session, not just initial load.
  • A popup, toast, or lazy-loaded section appearing 30 seconds later still contributes.
  • Fix: Reserve space for all dynamic content, including modals and banners.
How do you type the web-vitals metric callback in TypeScript?
import type { CLSMetric, INPMetric, LCPMetric } from "web-vitals";
 
function handleMetric(metric: CLSMetric | INPMetric | LCPMetric) {
  // metric.rating is typed as "good" | "needs-improvement" | "poor"
  console.log(metric.name, metric.value, metric.rating);
}
How do you type the useReportWebVitals callback in a Next.js app with TypeScript?
  • The callback receives a NextWebVitalsMetric type from next/web-vitals.
  • The rating field is "good" | "needs-improvement" | "poor".
  • The name field is a union of metric names like "LCP", "INP", "CLS", etc.
Why should the priority prop only be on one or two images per page?
  • priority disables lazy loading and adds a <link rel="preload"> tag.
  • Preloading too many images competes for bandwidth and can actually slow down the LCP image.
  • Only the image identified as the Largest Contentful Paint element needs priority.
How does useDeferredValue differ from useTransition for INP optimization?
  • useTransition wraps a state update to mark it as non-urgent.
  • useDeferredValue defers the consumption of a value, showing stale data while React re-renders.
  • Both keep the UI responsive; useDeferredValue is better when you do not control the state update.
What is TTFB and why does caching mask its problems?
  • TTFB (Time to First Byte) measures how long the browser waits for the server's first response byte; target is under 800ms.
  • Repeat visits with cached resources show excellent TTFB, hiding slow-server issues that first-time visitors experience.
  • Fix: Test with cache disabled and from different geographic locations.