Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
// 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.
// ---- 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:
useTransition defers heavy filtering = 380ms to 85ms (78% faster)next/image dimensions + next/font + reserved space = 0.28 to 0.03 (89% reduction)framer-motion import: 44KB savings from initial bundleMeasuring 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>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.rating field is typed as "good" | "needs-improvement" | "poor".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.
| 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 |
useEffect runs.useTransition marks the state update as non-urgent, so React keeps the UI responsive to user input.isPending opacity) instead of a frozen UI.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.sendBeacon is reliable during page unload (e.g., when the user navigates away).fetch may be cancelled by the browser when the page is torn down.fetch with keepalive: true as a fallback when sendBeacon is unavailable.npm run build && npm start.web-vitals) comes from actual user sessions with varying devices and networks.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);
}NextWebVitalsMetric type from next/web-vitals.rating field is "good" | "needs-improvement" | "poor".name field is a union of metric names like "LCP", "INP", "CLS", etc.priority disables lazy loading and adds a <link rel="preload"> tag.priority.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.useDeferredValue is better when you do not control the state update.Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥