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.8sWhen 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:
useTransitiondefers heavy filtering = 380ms to 85ms (78% faster) - CLS:
next/imagedimensions +next/font+ reserved space = 0.28 to 0.03 (89% reduction) - TTFB: Server Component rendering streams HTML immediately
- Removed
framer-motionimport: 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-vitalslibrary provides TypeScript types for all metric objects:CLSMetric,INPMetric,LCPMetric, etc. useReportWebVitalsfromnext/web-vitalsaccepts a callback withNextWebVitalsMetrictype.- The
ratingfield 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-vitalsfor 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
| Approach | Trade-off |
|---|---|
| Lighthouse | Lab testing; repeatable, does not reflect real users |
| PageSpeed Insights | Uses CrUX field data; limited to pages with enough traffic |
web-vitals library | Real user measurement; requires analytics infrastructure |
| Chrome UX Report (CrUX) | Field data from Chrome users; 28-day rolling window |
| Vercel Speed Insights | Automatic for Vercel deployments; vendor-specific |
| New Relic or Datadog RUM | Enterprise 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
useEffectruns. - 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?
useTransitionmarks 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.,
isPendingopacity) instead of a frozen UI.
What causes CLS from images, and how does next/image fix it?
- Images without explicit
widthandheightcause layout shift because the browser cannot reserve space until the image loads. next/imagerequires dimensions, automatically reserves the correct aspect ratio, and prevents shift.
Why should you use navigator.sendBeacon instead of fetch for reporting vitals?
sendBeaconis reliable during page unload (e.g., when the user navigates away).- A regular
fetchmay be cancelled by the browser when the page is torn down. - Use
fetchwithkeepalive: trueas a fallback whensendBeaconis 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
NextWebVitalsMetrictype fromnext/web-vitals. - The
ratingfield is"good" | "needs-improvement" | "poor". - The
namefield 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?
prioritydisables 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?
useTransitionwraps a state update to mark it as non-urgent.useDeferredValuedefers the consumption of a value, showing stale data while React re-renders.- Both keep the UI responsive;
useDeferredValueis 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.
Related
- Image & Font Performance — Optimizing LCP images and eliminating CLS from fonts
- Bundle Optimization — Reducing JS that blocks INP and FCP
- Suspense & Streaming — Streaming for faster TTFB and FCP
- Performance Checklist — Systematic CWV audit with CI enforcement