Optimizing Core Web Vitals in Next.js: LCP, INP, and CLS Strategies for Production React Applications
A practical guide to improving LCP, INP, and CLS in Next.js App Router applications. Covers image optimization, font loading, streaming server components, hydration cost reduction, useTransition, web workers, and layout shift prevention with real measurement code.
Core Web Vitals scores are a lagging indicator. By the time your CrUX data shows a problem, the regression has been shipping to users for 28 days. The teams that keep scores green do not react to dashboards. They instrument early, prevent regressions in CI, and know exactly which Next.js primitive controls which metric.
This guide covers each of the three Core Web Vitals with the specific Next.js patterns that move the numbers. Not theory. The actual configurations and code that make a measurable difference in production.
Measuring Before You Optimize
Field data and synthetic data answer different questions. Field data (CrUX, your own RUM) tells you what your actual users experience across their devices and networks. Synthetic data (Lighthouse CI, WebPageTest) tells you whether a specific commit caused a regression under controlled conditions. You need both.
The web-vitals library is the right starting point for field data collection in a Next.js App Router project:
// app/components/WebVitals.tsx
"use client";
import { useReportWebVitals } from "next/web-vitals";
interface VitalPayload {
name: string;
value: number;
rating: "good" | "needs-improvement" | "poor";
navigationType: string;
id: string;
}
export function WebVitals() {
useReportWebVitals((metric) => {
const payload: VitalPayload = {
name: metric.name,
value: Math.round(
metric.name === "CLS" ? metric.value * 1000 : metric.value
),
rating: metric.rating,
navigationType: metric.navigationType,
id: metric.id,
};
// Use sendBeacon so the request doesn't block page unload
if (navigator.sendBeacon) {
navigator.sendBeacon("/api/vitals", JSON.stringify(payload));
}
});
return null;
}
Mount this in your root layout:
// app/layout.tsx
import { WebVitals } from "@/components/WebVitals";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<WebVitals />
{children}
</body>
</html>
);
}
Segment field data by device category (mobile vs. desktop) and connection type. A p75 LCP of 2.1 seconds on desktop with a p75 of 5.4 seconds on mobile tells you the problem is not your server but your payload weight or render-blocking resources.
For CI enforcement, Lighthouse CI with a budget file catches regressions before they merge:
# lighthouserc.yml
ci:
collect:
numberOfRuns: 3
url:
- http://localhost:3000
- http://localhost:3000/dashboard
assert:
assertions:
largest-contentful-paint:
- error
- maxNumericValue: 2500
aggregationMethod: median
experimental-interaction-to-next-paint:
- error
- maxNumericValue: 200
aggregationMethod: median
cumulative-layout-shift:
- error
- maxNumericValue: 0.1
aggregationMethod: median
upload:
target: temporary-public-storage
Three runs with aggregationMethod: median reduces false positives from measurement variance without requiring five or ten runs.
LCP: Getting the Main Content on Screen Faster
LCP measures when the largest visible element in the viewport is rendered. In most Next.js apps, that element is a hero image, a product image, or a large heading. The optimizations fall into four categories: image delivery, font loading, server response time, and resource hints.
Image Optimization with next/image
The next/image component handles resizing, format conversion (WebP, AVIF), and lazy loading automatically. For the LCP image, you need to opt out of lazy loading and add a priority hint:
// app/page.tsx
import Image from "next/image";
export default function HomePage() {
return (
<section>
<Image
src="/hero.jpg"
alt="Product overview"
width={1200}
height={630}
priority
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px"
quality={85}
/>
</section>
);
}
The priority prop does two things: it adds fetchpriority="high" to the image element and removes the lazy loading attribute. Without it, the browser waits until layout is complete before requesting the image. With it, the browser fetches the image as early as possible in the waterfall.
The sizes attribute tells the browser which source to fetch before layout is computed. If you omit it, the browser defaults to 100vw, which means it may fetch a 1200px image for a 375px mobile viewport. Define sizes accurately and you cut image weight by 50-70% on mobile without any visual quality change.
For remotely hosted images, configure the allowed domains in next.config.ts:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "cdn.yourapp.com",
pathname: "/images/**",
},
],
formats: ["image/avif", "image/webp"],
deviceSizes: [375, 640, 750, 828, 1080, 1200, 1920],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
},
};
export default nextConfig;
AVIF delivers 30-50% smaller file sizes than WebP at equivalent visual quality. Next.js will serve AVIF to browsers that support it and fall back to WebP. The tradeoff is encoding CPU cost on the server, which Next.js mitigates by caching generated images.
Font Loading Strategies
Fonts that load after the page renders cause layout shifts and visual instability. Next.js has a built-in font system (next/font) that inlines font CSS, eliminates the network round-trip to Google Fonts, and prevents layout shift by using size-adjust to match fallback font metrics:
// app/layout.tsx
import { Inter, JetBrains_Mono } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
preload: true,
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
display: "swap",
variable: "--font-mono",
weight: ["400", "500"],
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={`${inter.variable} ${jetbrainsMono.variable}`}>
<body className="font-sans">{children}</body>
</html>
);
}
The display: "swap" setting shows text immediately in the fallback font, then swaps to the loaded font. This prevents invisible text (FOIT) but can cause a brief layout shift when the swap happens. next/font calculates a size-adjust value for the fallback font to minimize the visual difference, which in practice reduces CLS contribution from fonts to near zero.
For local fonts (self-hosted), use next/font/local with the same API. The performance characteristics are identical because next/font downloads Google Fonts at build time and serves them from your own domain.
Streaming Server Components for Perceived LCP
With the App Router, you can stream HTML as it is generated on the server. This means the browser receives and renders above-the-fold content before the full page is ready. Wrap slow data fetches in Suspense boundaries:
// app/dashboard/page.tsx
import { Suspense } from "react";
import { HeroSection } from "@/components/HeroSection";
import { UserStats } from "@/components/UserStats";
import { StatsSkeleton } from "@/components/StatsSkeleton";
export default function DashboardPage() {
return (
<main>
{/* This renders immediately — no data dependency */}
<HeroSection />
{/* This streams in once the data fetch completes */}
<Suspense fallback={<StatsSkeleton />}>
<UserStats />
</Suspense>
</main>
);
}
// app/components/UserStats.tsx
async function UserStats() {
// This fetch happens on the server, not the client
const stats = await fetchUserStats();
return (
<div>
<p>{stats.totalUsers}</p>
</div>
);
}
The key insight here is that HeroSection renders synchronously in the initial HTML response. The browser starts parsing it and can request the LCP image before UserStats has finished its data fetch. Without Suspense, Next.js would wait for all server components to resolve before sending anything.
INP: Keeping Interactions Responsive
INP measures the latency between user input and the next frame paint, aggregated as the worst interaction in a session (at p98). It replaced FID in March 2024. The threshold for “good” is under 200ms.
The most common sources of slow INP in Next.js applications are hydration cost on page load, synchronous state updates that trigger expensive renders, and JavaScript that runs in response to interaction that should not run at all.
Reducing Hydration Cost
Hydration is the process of attaching React event listeners to server-rendered HTML. In the App Router, only Client Components hydrate. But if your page has many Client Components, hydration can consume 200-500ms of main thread time on mid-range devices, leaving the page visually rendered but not interactive.
The fix is to push Client Component boundaries as deep as possible. A common mistake:
// Bad: the entire page becomes a Client Component
"use client";
import { useState } from "react";
export default function ProductPage({ product }: { product: Product }) {
const [quantity, setQuantity] = useState(1);
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
{/* Only this part actually needs client state */}
<input
type="number"
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))}
/>
</div>
);
}
The correct pattern extracts the interactive part:
// app/products/[id]/page.tsx — Server Component
import { QuantitySelector } from "@/components/QuantitySelector";
export default async function ProductPage({
params,
}: {
params: { id: string };
}) {
const product = await getProduct(params.id);
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
{/* Only this component hydrates */}
<QuantitySelector productId={product.id} />
</div>
);
}
// app/components/QuantitySelector.tsx — Client Component
"use client";
import { useState } from "react";
export function QuantitySelector({ productId }: { productId: string }) {
const [quantity, setQuantity] = useState(1);
return (
<input
type="number"
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))}
/>
);
}
The hydration budget shrinks from the entire page to a single small component. This directly reduces the time-to-interactive window where INP events get queued but not processed.
useTransition for Expensive State Updates
Some state updates are legitimately expensive: filtering a large list, re-sorting a table, or triggering a route transition that loads new data. useTransition marks these as non-urgent, letting React yield to higher-priority work (like the click that triggered the update):
"use client";
import { useState, useTransition, useDeferredValue } from "react";
interface Product {
id: string;
name: string;
category: string;
price: number;
}
export function ProductFilter({ products }: { products: Product[] }) {
const [filter, setFilter] = useState("");
const [isPending, startTransition] = useTransition();
const deferredFilter = useDeferredValue(filter);
// The filter computation runs at low priority
const filtered = products.filter((p) =>
p.name.toLowerCase().includes(deferredFilter.toLowerCase())
);
function handleFilterChange(value: string) {
// Input update is urgent — runs synchronously
setFilter(value);
// If there were additional expensive state updates, wrap them here
startTransition(() => {
// side effects like analytics, or additional expensive state
});
}
return (
<div>
<input
value={filter}
onChange={(e) => handleFilterChange(e.target.value)}
placeholder="Filter products..."
/>
{isPending && <span>Updating...</span>}
<ul style={{ opacity: isPending ? 0.7 : 1 }}>
{filtered.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</div>
);
}
useDeferredValue is the companion pattern: the input stays responsive because it updates with the urgent priority, while the filtered list re-renders with the deferred value at lower priority. On slow devices this is the difference between a janky filter and a smooth one.
Web Worker Offloading for CPU-Intensive Work
JavaScript that runs on the main thread blocks rendering. If a user interaction triggers a computation that takes 80ms, the browser cannot paint until that computation is done. Move it off the main thread:
// app/workers/data-processor.worker.ts
type WorkerMessage =
| { type: "PROCESS"; payload: number[] }
| { type: "SORT"; payload: Record<string, unknown>[] };
type WorkerResponse =
| { type: "PROCESSED"; result: number }
| { type: "SORTED"; result: Record<string, unknown>[] };
self.onmessage = (event: MessageEvent<WorkerMessage>) => {
const { type, payload } = event.data;
if (type === "PROCESS") {
// CPU-intensive work happens here, not on the main thread
const result = payload.reduce((sum, n) => sum + n * Math.sqrt(n), 0);
const response: WorkerResponse = { type: "PROCESSED", result };
self.postMessage(response);
}
if (type === "SORT") {
const result = [...payload].sort((a, b) =>
JSON.stringify(a).localeCompare(JSON.stringify(b))
);
const response: WorkerResponse = { type: "SORTED", result };
self.postMessage(response);
}
};
// app/hooks/useWorker.ts
"use client";
import { useEffect, useRef, useCallback } from "react";
export function useDataProcessor() {
const workerRef = useRef<Worker | null>(null);
useEffect(() => {
workerRef.current = new Worker(
new URL("../workers/data-processor.worker.ts", import.meta.url)
);
return () => {
workerRef.current?.terminate();
};
}, []);
const processData = useCallback(
(data: number[]): Promise<number> => {
return new Promise((resolve) => {
if (!workerRef.current) return;
workerRef.current.onmessage = (event) => {
if (event.data.type === "PROCESSED") {
resolve(event.data.result);
}
};
workerRef.current.postMessage({ type: "PROCESS", payload: data });
});
},
[]
);
return { processData };
}
Next.js supports Web Workers via webpack’s worker-loader integration. The new URL(..., import.meta.url) syntax is the standard way to reference worker files in bundlers. On the Next.js side, no additional configuration is required in App Router projects.
CLS: Preventing Layout Shifts
CLS accumulates across the entire session. Elements that shift while a user is reading or interacting cause the score to climb. The threshold for “good” is 0.1, which is less permissive than it sounds: a single image without dimensions loading above the fold can push you above it.
Aspect Ratio Reservation for Images and Media
Reserve space before content loads. Any element with unknown dimensions at render time is a potential CLS contributor. The CSS aspect-ratio property is the right tool:
// app/components/ProductImage.tsx
import Image from "next/image";
interface ProductImageProps {
src: string;
alt: string;
aspectRatio?: "16/9" | "4/3" | "1/1" | "3/4";
}
export function ProductImage({
src,
alt,
aspectRatio = "16/9",
}: ProductImageProps) {
return (
<div
style={{
aspectRatio,
position: "relative",
overflow: "hidden",
backgroundColor: "#f5f5f5",
}}
>
<Image
src={src}
alt={alt}
fill
sizes="(max-width: 768px) 100vw, 50vw"
style={{ objectFit: "cover" }}
/>
</div>
);
}
The fill prop combined with a positioned container means the image fills its parent without affecting layout. The container’s dimensions are known at render time because aspect-ratio computes them from the container width. Nothing shifts when the image loads.
Skeleton Screens for Dynamic Content
Skeleton screens reserve space and manage user expectations simultaneously. The key constraint: the skeleton must match the dimensions of the real content closely enough that the swap does not cause a layout shift:
// app/components/UserCardSkeleton.tsx
export function UserCardSkeleton() {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: 16,
// Must match UserCard's dimensions
minHeight: 72,
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: "50%",
backgroundColor: "#e5e7eb",
flexShrink: 0,
}}
aria-hidden="true"
/>
<div style={{ flex: 1 }}>
<div
style={{
height: 14,
width: "60%",
backgroundColor: "#e5e7eb",
borderRadius: 4,
marginBottom: 6,
}}
aria-hidden="true"
/>
<div
style={{
height: 12,
width: "40%",
backgroundColor: "#e5e7eb",
borderRadius: 4,
}}
aria-hidden="true"
/>
</div>
</div>
);
}
A common mistake with skeletons is sizing them by guess. Measure the real component’s rendered height and hardcode it in the skeleton. If the real component is dynamic in height, set a min-height that covers the common case and accept a small downward shift for content that renders taller.
Dynamic Content Reservation
Banners, cookie notices, and notifications that appear after initial render are CLS landmines if they push content down. Reserve the space at render time even when the content is not ready:
// app/components/NotificationBanner.tsx
"use client";
import { useState, useEffect } from "react";
const BANNER_HEIGHT = 48; // px
export function NotificationBanner() {
const [message, setMessage] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
// Fetch notification after mount
fetchActiveNotification().then((msg) => {
setMessage(msg);
setLoaded(true);
});
}, []);
return (
// Always reserve the height — prevents layout shift when message appears
<div style={{ height: BANNER_HEIGHT, overflow: "hidden" }}>
{loaded && message && (
<div
style={{
height: BANNER_HEIGHT,
backgroundColor: "#1d4ed8",
color: "white",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{message}
</div>
)}
</div>
);
}
The outer div always occupies BANNER_HEIGHT pixels. Whether the banner is showing or not, nothing below it shifts. This is a deliberate trade: you reserve space that may sometimes be blank. That is the correct call for above-the-fold content where CLS contributions have the highest weight.
For content well below the fold, reserving space is less critical because the CLS weight formula discounts shifts in areas users have not scrolled to yet.
Tradeoffs at a Glance
| Technique | LCP impact | INP impact | CLS impact | Implementation cost |
|---|---|---|---|---|
next/image with priority | High | None | Low (prevents shift) | Low |
next/font | Low | None | High | Low |
| Streaming with Suspense | High | None | Medium (skeleton needed) | Medium |
| Server Component decomposition | Low | High | None | Medium |
useTransition / useDeferredValue | None | High | None | Low |
| Web Worker offloading | None | High | None | High |
| Aspect ratio containers | None | None | High | Low |
| Dynamic content reservation | None | None | High | Low |
The low-hanging fruit: next/image with priority on your LCP element, next/font for all typefaces, and aspect ratio containers on media. These are configuration changes, not architecture changes, and they consistently move LCP and CLS into the green range for most Next.js sites.
The INP improvements require more judgment. Reducing hydration surface area requires a deliberate audit of where "use client" appears in your component tree. useTransition is only useful when you have an interaction that triggers a visibly slow state update. Web Workers are worth the overhead when a specific computation is measurably blocking the main thread.
Production Monitoring Setup
Lighthouse CI tells you whether a specific commit regressed. CrUX tells you what your users actually experience. Real User Monitoring (RUM) with the web-vitals library fills the gap between the two: your own data, segmented by page, device, and user cohort.
A minimal API route to receive vitals:
// app/api/vitals/route.ts
import { NextRequest, NextResponse } from "next/server";
interface VitalPayload {
name: string;
value: number;
rating: "good" | "needs-improvement" | "poor";
navigationType: string;
id: string;
}
export async function POST(request: NextRequest): Promise<NextResponse> {
try {
const payload = (await request.json()) as VitalPayload;
// Validate the payload before forwarding
if (!payload.name || typeof payload.value !== "number") {
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
}
// Forward to your metrics backend (Datadog, Grafana, custom)
await sendToMetricsBackend({
metric: payload.name,
value: payload.value,
rating: payload.rating,
page: request.headers.get("referer") ?? "unknown",
userAgent: request.headers.get("user-agent") ?? "unknown",
timestamp: Date.now(),
});
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
async function sendToMetricsBackend(data: Record<string, unknown>) {
// Replace with your actual metrics destination
await fetch(process.env.METRICS_ENDPOINT!, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
}
Track p75 values per page per device category. The p75 threshold is what Google uses for Search ranking decisions. An improvement in average that does not move p75 is not an improvement in your CrUX score.
The Order That Matters
If you are starting from a baseline you have not measured yet, run this sequence:
- Pull your CrUX data from Google Search Console. Identify which pages are red and which metric is failing.
- Set up the web-vitals RUM instrumentation to capture your own field data segmented by page.
- Configure Lighthouse CI to run on pull requests with budgets at your current p75 values minus 10%.
- Fix
next/imagepriority and sizing on LCP elements. This is the single highest-leverage change for most Next.js sites. - Audit
"use client"boundaries. Push them as far down the component tree as possible. - Implement aspect ratio containers on all media and skeleton screens for Suspense boundaries.
- Tighten Lighthouse CI budgets as scores improve.
The pattern that consistently fails is optimizing before measuring. Engineers add React.memo everywhere, split bundles aggressively, and then discover the LCP element was a font that loaded three round-trips after the HTML. Measure first. The actual bottleneck is almost always something different from what intuition suggests.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.