Core Web Vitals have been a ranking factor for a while, but App Router introduced new patterns that affect how you optimize for them. Some old approaches don't apply. Some new ones are surprisingly easy to miss.
Here's what actually moves the needle for LCP, CLS, and INP in a Next.js App Router application — from lessons building a production app — the same patterns power the free AI image generator high quality output at pixova.io.
The Three Metrics — What Changed in App Router
LCP (Largest Contentful Paint): The largest visible element loading fast. In App Router, Server Components help here because content can start streaming before JavaScript loads. But images are still the most common LCP element and need explicit attention.
CLS (Cumulative Layout Shift): Visual stability — elements not jumping around as the page loads. App Router's streaming model can actually make CLS worse if you're not careful about skeleton sizing.
INP (Interaction to Next Paint): Replaced FID as a metric in 2024. Measures responsiveness to user interactions. React's concurrent rendering in App Router helps here, but heavy Client Components can still block the main thread.
LCP — The Image Problem
The most common LCP failure in Next.js applications: images that load too slowly, or that aren't identified as the LCP element.
Step 1 — Identify your LCP element
Open Chrome DevTools → Performance → record a page load → look for the LCP marker. In most content-heavy pages, it's an image. In App Router applications, it might be a server-rendered text block.
Step 2 — Add priority to your LCP image
// Without priority — image loads lazily (wrong for LCP)
<Image src={heroImage} alt="Hero" width={1200} height={630} />
// With priority — image preloaded immediately (correct for LCP)
<Image
src={heroImage}
alt="Hero"
width={1200}
height={630}
priority // Only use on above-the-fold LCP images
/>
priority tells Next.js to preload this image and skip lazy loading. Only use it on the single LCP element — adding it to multiple images defeats the purpose.
Step 3 — Preconnect to external image domains
// app/layout.js
export default function RootLayout({ children }) {
return (
<html>
<head>
<link rel="preconnect" href="https://your-cdn.com" />
<link rel="dns-prefetch" href="https://your-cdn.com" />
</head>
<body>{children}</body>
</html>
);
}
This starts the connection to your image CDN before the image request fires, saving DNS lookup and TLS handshake time.
Step 4 — Use proper sizes attribute
// Wrong — browser downloads full-size image for mobile
<Image src={image} alt="" width={1200} height={630} />
// Right — browser downloads appropriate size for viewport
<Image
src={image}
alt=""
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
The sizes attribute tells the browser what size the image will be rendered at, letting it download the appropriately sized version instead of always downloading the full-size image.
CLS — The Streaming Problem
App Router's streaming with Suspense is excellent for performance, but introduces a CLS risk: if Suspense boundaries don't have sized fallbacks, content below them shifts when they resolve.
// CLS problem — unsized fallback causes layout shift
<Suspense fallback={<div>Loading...</div>}>
<UserProfile />
</Suspense>
// CLS fixed — fallback matches expected content size
<Suspense fallback={<UserProfileSkeleton />}>
<UserProfile />
</Suspense>
// UserProfileSkeleton — must match UserProfile dimensions exactly
function UserProfileSkeleton() {
return (
<div className="flex items-center gap-3 p-4">
<div className="w-10 h-10 rounded-full bg-neutral-200 animate-pulse" />
<div className="flex flex-col gap-2">
<div className="h-4 w-32 bg-neutral-200 rounded animate-pulse" />
<div className="h-3 w-24 bg-neutral-200 rounded animate-pulse" />
</div>
</div>
);
}
The rule: Every Suspense boundary fallback must be dimensionally identical to what it's replacing. Any size difference between fallback and resolved content = CLS.
CLS — Font Loading
Custom fonts are another common CLS source. Text renders in the fallback font, then shifts when the custom font loads.
// app/layout.js
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap', // Show fallback font immediately
preload: true, // Preload font files
// Optional: reduce FOUT with size-adjust
adjustFontFallback: true,
});
export default function RootLayout({ children }) {
return (
<html className={inter.className}>
<body>{children}</body>
</html>
);
}
next/font handles font optimization automatically — it self-hosts Google Fonts, eliminates the network request to Google's servers, and generates size-adjust CSS to reduce the visual shift when the font loads.
INP — Client Component Optimization
INP measures how quickly the page responds to user input. Long tasks on the main thread cause high INP. In App Router, the main culprits:
Heavy Client Components loading too early:
// Loads everything upfront — heavy JS bundle blocks INP
import { HeavyEditor } from '@/components/HeavyEditor';
// Better — loads only when needed
import dynamic from 'next/dynamic';
const HeavyEditor = dynamic(() => import('@/components/HeavyEditor'), {
loading: () => <EditorSkeleton />,
ssr: false, // Client-only component
});
Event handlers doing too much synchronously:
// INP problem — blocks main thread for 200ms
function handleClick() {
const result = heavyComputation(); // Blocks
setResult(result);
}
// Better — defer non-urgent work
function handleClick() {
// Immediate UI feedback
setIsLoading(true);
// Defer heavy work
setTimeout(() => {
const result = heavyComputation();
setResult(result);
setIsLoading(false);
}, 0);
}
Measuring Progress
Don't rely on Lighthouse scores alone — they're simulated and don't reflect real user experience. Use these alongside:
PageSpeed Insights — runs Lighthouse but also shows field data from Chrome UX Report (real users, not simulation).
Google Search Console → Core Web Vitals — actual performance data from your real visitors, segmented by mobile/desktop.
Web Vitals library for in-app measurement:
import { onLCP, onCLS, onINP } from 'web-vitals';
onLCP(metric => sendToAnalytics(metric));
onCLS(metric => sendToAnalytics(metric));
onINP(metric => sendToAnalytics(metric));
Simulate → measure in field → fix → repeat. Lab scores that don't match field data usually mean the simulation is missing something about your real environment.
The Checklist
- [ ] LCP image has
priorityprop - [ ] LCP image has correct
sizesattribute - [ ] CDN domain has
preconnectlink - [ ] All Suspense fallbacks match resolved content dimensions
- [ ] Fonts use
next/font(not<link>to Google Fonts) - [ ] Heavy Client Components are dynamically imported
- [ ] Event handlers don't block the main thread
- [ ] Measuring with field data, not just Lighthouse Most Core Web Vitals improvements come from fixing one or two specific issues, not broad optimization. Identify the actual problem in field data first, then fix that.
Common Mistakes I Made (And Fixed)
Mistake 1: priority on every above-fold image
I added priority to four images thinking "more is better." It broke the preload order — the browser couldn't prioritize when everything claimed priority. Fixed: priority on one LCP image only.
Mistake 2: Suspense fallbacks that were too short
A Suspense boundary with a one-line "Loading..." fallback replaced a card that was 200px tall. The 190px shift when content resolved was catastrophic for CLS. Fixed: skeleton components that exactly match resolved component dimensions.
Mistake 3: Measuring only with Lighthouse
Lighthouse showed a 90 performance score. Real users on mobile 4G were seeing 4+ second LCP. The simulation doesn't model real network conditions accurately. Fixed: monitoring with Search Console field data as the source of truth.
Mistake 4: Importing all icons at component level
// Wrong — imports entire icon library
import * as Icons from 'react-icons/all';
// Right — imports only what's needed
import { FiDownload, FiShare } from 'react-icons/fi';
Tree-shaking requires named imports. Default imports of entire libraries bypass tree-shaking and bloat the bundle significantly.
Real Numbers
After working through this checklist on a production application:
- LCP improved from 4.2s to 1.8s (mobile, 75th percentile field data)
- CLS dropped from 0.18 to 0.02 (CLS threshold is 0.1)
- INP improved from 340ms to 95ms
The LCP improvement came entirely from
priority+preconnect. The CLS fix was entirely skeleton sizing. The INP improvement was dynamic importing of a heavy client component.
Three fixes, significant results. Core Web Vitals optimization is usually like this — find the specific bottleneck, fix it precisely, measure the improvement.
Top comments (0)