Web / Performance
Next.js Performance Optimization: A Core Web Vitals Guide
Core Web Vitals are three field metrics — Largest Contentful Paint (LCP) under 2.5 s, Interaction to Next Paint (INP) under 200 ms, and Cumulative Layout Shift (CLS) under 0.1 — measured at the 75th percentile of real users. In a Next.js App Router application, the highest-leverage fixes are almost always the same: preload the LCP image, move fonts to next/font, defer third-party scripts, stream slow data behind Suspense, and cut client JavaScript with Server Components.
The Three Metrics and Their Targets
Core Web Vitals replaced vague "site speed" with three user-centric questions, each with a threshold that Google publishes and uses as a ranking signal. The thresholds apply to real user data at the 75th percentile over a rolling 28-day window — not to your Lighthouse score on a desktop machine.
| Metric | Measures | Good | Needs work / Poor |
|---|---|---|---|
| LCP | Loading — when the main content paints | ≤ 2.5 s | 2.5–4.0 s / > 4.0 s |
| INP | Responsiveness — latency after an input | ≤ 200 ms | 200–500 ms / > 500 ms |
| CLS | Visual stability — unexpected movement | ≤ 0.1 | 0.1–0.25 / > 0.25 |
Note the metric rename: INP replaced First Input Delay (FID) in March 2024 because FID only measured the first interaction and said nothing about the rest of the session. INP observes interactions throughout the visit and reports a near-worst-case value, which is why long tasks, heavy hydration, and large component trees that re-render on every keystroke now show up directly in your score.
LCP: Find It, Then Fix It
LCP is usually one element: the hero image, the headline in a card, or the first block of article text. Start by identifying it in Chrome DevTools under the Performance panel — guessing is how teams spend weeks optimizing a sidebar that was never the LCP candidate.
The four usual causes, in the order they appear in real projects:
- Slow LCP resource discovery — the image sits deep in the HTML and the browser discovers it late. Fix with
priorityonnext/image, which emits a preload; for non-image LCP, a manual<link rel="preload">. - Slow resource load — oversized source, unoptimized format, or a slow origin. Fix with modern formats, correct
sizes, responsive variants, and an image CDN ornext/imageoptimization. - Render-blocking resource — a synchronous stylesheet, an unoptimized web font, or a blocking script in
<head>. Fix withnext/fontand deferred scripts. - Client-side rendering of the hero — the largest element only exists after JavaScript runs and fetches data. Fix by rendering it on the server: an async Server Component with a cached fetch, or a static shell via Partial Prerendering.
import Image from "next/image";
// The LCP element: preload it, pin its aspect ratio, serve modern formats
<Image
src="/hero/product-dashboard.webp"
alt="Product analytics dashboard overview"
width={1440}
height={900}
priority
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 70vw, 1200px"
placeholder="blur"
/>Three details in that snippet carry most of the value. priority preloads the image and sets fetch priority high — use it on exactly one element, the LCP candidate. sizesprevents the browser from downloading a 1440 px asset for a 375 px viewport; without it, next/image falls back to a single-src layout and can serve an unnecessarily large file. width and height (or an aspect-ratio wrapper) reserve space so the image does not shift content when it arrives — that is your CLS budget spent or saved.
Under the hood, next/image serves WebP or AVIF, generates responsive srcset candidates, lazy-loads below-the-fold images by default, and honors deviceSizes and imageSizes from your config. Everything except the priority image should stay lazy.
Font Optimization: Kill FOIT and Font-Induced CLS
Web fonts are a double penalty: a flash of invisible text (FOIT) while the font loads, then a layout shift when the fallback metrics differ from the final font. Unoptimized fonts are one of the most common single causes of a CLS above 0.1.
next/font resolves both problems at build time: it self-hosts fonts (no request to Google's font CDN from your users), generates size-adjust, ascent-override, and related metrics so the fallback matches the real font dimensions, and inlines a preload link in the HTML.
// app/layout.tsx — zero layout shift, zero FOIT, self-hosted
import { Inter, JetBrains_Mono } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-sans",
preload: true,
});
const mono = JetBrains_Mono({
subsets: ["latin"],
display: "swap",
variable: "--font-mono",
});
export default function RootLayout({ children }) {
return (
<html lang="en" className={`${inter.variable} ${mono.variable}`}>
<body className="font-sans">{children}</body>
</html>
);
}Practical rules: keep it to one or two families with the weights you actually use (every extra weight is another file on the critical path), always display: "swap" so text is readable immediately, and prefer variable fonts when you need more than two weights. For truly non-critical display fonts, pair font-display: optional with a preload if you would rather skip the swap than wait.
Script Management with next/script
Third-party scripts — analytics, tag managers, chat widgets, A/B testing, heatmaps — are the most frequent cause of INP regressions, because they inject long tasks onto the main thread exactly while the user is trying to interact. A synchronous tag manager in <head> can single-handedly push interaction latency past 200 ms.
next/script exposes four strategies that map to intent rather than to DOM placement:
import Script from "next/script";
export default function Page() {
return (
<>
{/* Analytics: after hydration, never blocks paint */}
<Script
src="https://example.com/analytics.js"
strategy="afterInteractive"
/>
{/* Non-critical widget: idle, off the interaction path */}
<Script
src="https://example.com/widget.js"
strategy="lazyOnload"
/>
{/* Critical only if it must run before interactive: */}
<Script id="ld-json" type="application/ld+json"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }} />
</>
);
}The discipline matters more than the API. Audit your third-party inventory quarterly: every script that is not load-bearing for first paint or first interaction should be lazyOnload or removed. Load tag managers only after interaction, gate chat widgets behind user intent (a click on "Support" rather than page load), and prefer server-side event collection so analytics never touch the main thread. Use partytown or an equivalent off-main-thread approach for scripts that must run early but cannot afford the jank.
Streaming and Suspense for LCP and INP
The App Router streams HTML by default, and Suspense is how you shape that stream. The strategy is counterintuitive: put the fast, cacheable content in the shell and let slow queries resolve in the background, so the LCP element paints before the slow data exists.
// Shell renders immediately; slow data streams in behind Suspense
import { Suspense } from "react";
export default async function DashboardPage() {
return (
<main>
<Header /> {/* instant, cached */}
<HeroMetrics /> {/* LCP candidate — keep it fast */}
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity /> {/* streams when the query resolves */}
</Suspense>
<Suspense fallback={<ReportSkeleton />}>
<QuarterlyReport /> {/* independent — does not block */}
</Suspense>
</main>
);
}Without Suspense, an await in any part of the page holds the entire response until every query settles — a 900 ms report query delays a hero that could have painted in 100 ms. With Suspense, those two queries are independent. A loading.tsx file gives every route a top-level boundary for free.
Two rules keep streaming from backfiring: never wrap the LCP element in Suspense if its data is fast (the fallback is what gets measured), and make sure fallback skeletons match the final dimensions so streaming does not convert an LCP problem into a CLS problem. Partial Prerendering pushes this further — a fully prerendered static shell plus streamed dynamic holes — giving CDN-fast first paint with live data.
Reducing Client JavaScript with Server Components
INP is largely a JavaScript-execution metric. Every kilobyte you ship to the browser is parsed, compiled, and executed — and hydration work competes directly with the user's first interactions. The App Router's most important performance feature is that components are server-only by default: they ship zero JavaScript.
The pattern is subtraction. Identify what actually needs the browser — event handlers, browser APIs, state, refs, effects — and push everything else above the "use client" boundary:
- Fetch on the server — data libraries, ORMs, and SDKs never enter the client bundle, and no request waterfall forms in the browser.
- Move the boundary down — a page marked
"use client"pulls its whole subtree clientward; marking only the button that needs a click handler keeps the rest server-rendered. - Ship interactivity as Server Actions — a mutation can be a server function invoked from a client form, so the action logic never becomes client code.
- Guard secrets — add
import "server-only"to server modules so an accidental import fails the build instead of leaking a key.
Measure the result: the JS payload tab of the Network panel, or your bundle analyzer, should shrink meaningfully when a route flips from fully-client to mostly-server. That reduction shows up first in INP and in hydration time.
Bundle Analysis and Code Splitting
Before optimizing, instrument. A bundle analyzer tells you which dependency dominates a route, and it routinely reveals surprises — an icon library pulling its entire set, a date library importing locales, a rich-text editor bundled into a page that never edits text.
// next.config.ts — measure before you optimize
const nextConfig = {
optimizePackageImports: ["lodash-es", "date-fns", "radix-ui"],
};
// Run: npx @next/bundle-analyzer
// Then dynamic-import the heavy, rarely-used subtree:
import dynamic from "next/dynamic";
const ChartEditor = dynamic(
() => import("@/components/chart-editor"),
{
loading: () => <ChartEditorSkeleton />,
ssr: false,
}
);Next.js code-splits by route automatically, so the JavaScript for the page you are on is not shipped to every other page. The wins come from within a route:
- Dynamic import the heavy and rare — modals, editors, charts, PDF viewers, and anything behind a click should load on demand with
next/dynamic. - Import the module, not the barrel —
import { foo } from "big-lib"can drag the whole package if the library's exports are not tree-shakeable.optimizePackageImportsfixes this for common libraries at compile time. - Replace heavyweight generics — moment.js for formatting, lodash for one function, a 90 KB markdown editor for a read-only view. The expensive import is often avoidable entirely.
- Watch shared chunks — a utility imported by every route lands in the common bundle and blocks every page's hydration.
The Caching Layers You Actually Have
Caching in a Next.js application operates at three levels, and misconfiguring any one of them shows up as latency rather than as an error:
// Layer 1 — Data cache (server): memoized fetch with a staleness window
const res = await fetch(apiUrl + "/products", {
next: { revalidate: 300, tags: ["products"] },
});
// Layer 2 — Full route cache (server): prerender this route at build time
export const revalidate = 3600;
export const dynamic = "force-static";
// Layer 3 — Client router cache: how long prefetched RSC payloads
// stay fresh for navigation (next.config.js)
const nextConfig = {
experimental: {
staleTimes: { dynamic: 0, static: 300 },
},
};The data cache memoizes fetch results on the server across requests and deployments. Set revalidate per call so hot paths hit cache while fresh data still arrives within your staleness budget, and tag requests so you can invalidate the moment data changes instead of waiting for a timer.
The full route cache stores prerendered HTML and RSC payloads for static routes. The more of your site that is static, the more requests never reach your server at all — this is the single biggest infrastructure-level latency win available.
The client router cache keeps prefetched RSC payloads so back/forward and link navigation feel instant. It is also the layer that can serve stale content after a deploy, so tune staleTime deliberately: longer for stable content, zero for dashboards.
Outside the framework, put a CDN in front of anything static, set Cache-Control headers on Route Handlers, and cache expensive computed responses at the edge. Every cache hit you convert is origin time removed from a user's interaction.
CLS: The Stability Budget
CLS is unique among the three because it is entirely within your control — nothing about the network causes layout shift. The documented causes are narrow, and each has a canonical fix:
- Images without dimensions — always provide width/height or an aspect-ratio box;
next/imagedoes this for you. - Ads, embeds, and iframes — reserve a fixed-size slot before the third party inserts content, even if the slot is empty for a second.
- Late-injected banners — cookie consent, newsletter modals, and promo bars that prepend to the document push everything down. Position them fixed or append them after content.
- Web font swaps — solved by
next/font's metric overrides, or by matching fallback metrics yourself. - Animating into reserved space — skeletons that are shorter than their content. Match heights, or animate opacity only.
Use the Layout Shift Regions overlay in DevTools to see exactly which elements moved, and remember CLS is cumulative across the whole session — a shift five seconds after load from a delayed widget still counts.
Measurement: Lab vs Field
You need both, and they answer different questions.
Lab tools (Lighthouse, WebPageTest, DevTools traces) give you controlled, repeatable numbers and a diagnostic waterfall — ideal for finding the LCP element, spotting render-blocking resources, and verifying that a change helped. Their weakness is that they simulate one device and one network profile, so they predict field behavior only loosely.
Field data (Real User Monitoring) is what Core Web Vitals are actually scored on: the 75th percentile of real sessions across real devices. Google Search Console reports Core Web Vitals per URL group, and the web-vitals library (or an RUM vendor) reports the same metrics from your own users, segmented by page, device, and country.
The working loop is: read field data to find which metric and which route is failing, reproduce it in a lab trace, fix it, then confirm in field data over the next 28-day window. Note that site-wide changes take a full reporting window to move your Search Console numbers — lab data will show the improvement immediately.
Prioritized Checklist
Ordered by typical impact-to-effort ratio for a Next.js App Router application. Do them top to bottom; the first five rows resolve the majority of real-world regressions.
| Action | Metric improved | Effort |
|---|---|---|
| Identify and preload the LCP image with priority | LCP | Low |
| Move fonts to next/font with display: swap | LCP, CLS | Low |
| Set width/height or aspect-ratio on every media slot | CLS | Low |
| Defer third-party scripts to lazyOnload / afterInteractive | INP | Low |
| Add sizes + responsive srcset to next/image | LCP, bandwidth | Low |
| Wrap slow data in Suspense; keep the LCP element out | LCP | Medium |
| Push "use client" boundaries down; fetch on server | INP, LCP | Medium |
| Run bundle analyzer; dynamic-import modals and editors | INP | Medium |
| Set fetch revalidate + tags on hot data paths | LCP, TTFB | Medium |
| Prerender public routes (force-static / ISR) | LCP, TTFB | Medium |
| Audit layout-shift sources with the regions overlay | CLS | Medium |
| Gate chat/widgets behind user intent, not page load | INP | Medium |
| Replace heavy generic libraries (moment, full lodash) | INP, bandwidth | Medium |
| Tune client router cache staleTime per section | Responsiveness | Medium |
| Adopt Partial Prerendering on hybrid routes | LCP, TTFB | High |
| Move event collection server-side | INP | High |
| Wire field RUM + Search Console alerts per template | All (detection) | High |
Conclusion
Next.js gives you an unusual amount of leverage over Core Web Vitals because rendering, data fetching, image handling, font loading, and script placement are all framework primitives rather than hand-rolled conventions. The teams that hit good field numbers are not the ones with the cleverest micro optimizations — they are the ones who identified their LCP element, kept third parties off the main thread, rendered data on the server, and reserved space for everything that appears after first paint.
Treat performance as a budget you defend in review, not a cleanup phase after launch: measure in the field, fix the largest gap first, and keep client JavaScript from creeping back across the server boundary.
Key Takeaways
- Targets at p75 of real users: LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1 — measured over a rolling 28-day field window, not a Lighthouse run.
- LCP fixes: identify the element first, preload it with priority on next/image, provide sizes, serve modern formats, and avoid rendering it client-side.
- next/font self-hosts fonts and overrides fallback metrics, eliminating FOIT and font-driven CLS; next/script strategies keep third parties off the interaction path.
- Suspense lets slow queries stream in without delaying the shell — but keep the LCP element outside a fallback so streaming does not regress paint timing.
- Server Components, route-level code splitting, and the three caching layers (data, full route, client router) are the structural ways to cut INP and TTFB.
FAQ
What are the Core Web Vitals targets?
Largest Contentful Paint (LCP) at or below 2.5 seconds, Interaction to Next Paint (INP) at or below 200 milliseconds, and Cumulative Layout Shift (CLS) at or below 0.1. All three are judged at the 75th percentile of real user sessions over a rolling 28-day window; anything beyond those thresholds is rated "needs work" or "poor".
How do I improve LCP in Next.js?
Find the LCP element in the Performance panel, then fix its path to the screen: add priority to the hero next/image so it preloads, provide correct sizes for responsive delivery, move fonts into next/font so they are self-hosted and non-blocking, defer render-blocking scripts, and make sure the hero is server-rendered from cached data rather than mounted after a client-side fetch.
Why did INP replace FID?
First Input Delay only measured the delay before the very first interaction, so it missed sluggishness later in the session — the second click, the fifth keystroke. Interaction to Next Paint measures input latency across all interactions and reports a high-percentile value, which surfaces real problems like long JavaScript tasks, heavy hydration, and expensive re-renders during typing.
Does next/image make a difference to performance?
Yes, in four measurable ways: it converts images to WebP or AVIF, generates a responsive srcset so small viewports do not download desktop-sized files, lazy-loads everything below the fold, and — with priority — preloads the LCP image to shorten discovery time. It also reserves layout space via width and height, which protects CLS.
How do I measure Core Web Vitals in production?
Use real user monitoring rather than lab scores: the web-vitals JavaScript library reports LCP, INP, and CLS from actual sessions, and Google Search Console shows the same metrics per URL group as Google scores them. Pair that with Lighthouse or WebPageTest traces for diagnosis — field data tells you what is failing, lab data tells you why.
References
- web.dev. "Web Vitals." Google web.dev. web.dev/articles/vitals
- web.dev. "Optimize Largest Contentful Paint." web.dev/articles/optimize-lcp
- web.dev. "Optimize Interaction to Next Paint." web.dev/articles/optimize-inp
- Next.js. "Image Component and Image Optimization." Next.js Docs. nextjs.org/docs/app/api-reference/components/image
- Next.js. "Font Optimization (next/font)." Next.js Docs. nextjs.org/docs/app/building-your-application/optimizing/fonts
- Next.js. "Script Component." Next.js Docs. nextjs.org/docs/app/api-reference/components/script