Your Next.js app feels fast in development, but real users are timing out. Core Web Vitals in Next.js aren't just SEO buzzwords—they're the metrics Google uses to rank your site and the ones your paying customers actually experience. In this post, I'll walk you through the three metrics that matter most and the fixes that genuinely move them.
What Are Core Web Vitals?
Google measures user experience through three specific metrics: Largest Contentful Paint (LCP), Cumulative Layout Shift (CLS), and Interaction to Next Paint (INP). Think of them as your production health dashboard. Unlike synthetic metrics from Lighthouse, Core Web Vitals capture real traffic on real devices in real network conditions. That's why a "Perfect" Lighthouse score doesn't guarantee green vitals in the field.
I've seen projects score 90+ on Lighthouse but tank in the wild. The disconnect usually comes down to one thing: we optimize for the fast connection and powerful laptop we're running locally, not the 4G network on a mid-range Android phone that makes up most of your traffic.
LCP — Largest Contentful Paint
LCP measures when the largest visible element on the screen finishes rendering. Google wants this under 2.5 seconds. In practice, LCP is almost always blocked by either a large hero image, JavaScript parsing, or a slow backend query.
I worked on an e-commerce platform where the hero image was 2.8 MB. The server was fast, but browsers spent 3+ seconds downloading and decoding it. Users saw a blank page. That's a failed LCP.
Priority 1: Optimize the Hero Image
This is usually your biggest win. Use next/image with explicit width and height to prevent layout thrashing, and let it handle WebP conversion and responsive sizing:
import Image from 'next/image';
export default function Hero() {
return (
<Image
src="/hero.jpg"
alt="Product hero"
width={1200}
height={600}
priority
sizes="100vw"
style={{ width: '100%', height: 'auto' }}
/>
);
}The priority prop tells Next.js to preload this image and defer lower-priority images. Without it, Next.js lazy-loads images by default, which is smart for below-the-fold content but devastating for your hero.
Compress aggressively too. A 1200×600 hero should be under 100 KB. I use TinyPNG or Squoosh before upload, then let next/image handle the rest.
Priority 2: Server-Render Above-the-Fold Content
If your hero content comes from a client-side fetch, LCP is already broken. The browser has to download your JavaScript bundle, parse it, request data, parse the response, and render. That's a cascade of delays.
Use getStaticProps or getServerSideProps to fetch hero data at build or request time, so HTML includes the content:
export async function getStaticProps() {
const product = await fetchProduct('hero-item');
return {
props: { product },
revalidate: 3600, // ISR: revalidate hourly
};
}
export default function ProductPage({ product }) {
return (
<>
<h1>{product.name}</h1>
<Image
src={product.imageUrl}
alt={product.name}
width={1200}
height={600}
priority
/>
</>
);
}This way, your HTML already contains the heading and the image URL. Browsers can request the image immediately without waiting for JavaScript.
Priority 3: Use a CDN
Even a 50 KB image takes 500 ms over a slow connection if it's served from a single data center far away. Vercel's built-in CDN caches your assets globally. If you host elsewhere, use Cloudflare or AWS CloudFront to ensure assets are close to your users.
Real-world impact: On a CRM rebuild, moving images to a CDN and optimizing the hero cut LCP from 3.8 s to 1.9 s.
CLS — Cumulative Layout Shift
CLS measures unexpected movement of page elements. Click a button and the page jumps, shifting everything down—that's CLS. Google wants this under 0.1 (ideally close to 0). It's usually the most fixable metric and the one that feels most immediately broken to users.
I was reviewing a SaaS dashboard where every chart loaded asynchronously. As data arrived, the layout reflowed repeatedly. Users couldn't click buttons reliably because the target kept moving. CLS was 0.35—failed.
Reserve Space for Images, Ads, and Embeds
The core fix: know the dimensions of dynamic content before it loads. If an image will eventually be 600×400, tell the browser that now, so it reserves the space and nothing shifts:
import Image from 'next/image';
export default function ProductCard({ product }) {
return (
<div style={{ position: 'relative', width: '300px', height: '200px' }}>
<Image
src={product.image}
alt={product.name}
fill
style={{ objectFit: 'cover' }}
/>
</div>
);
}The parent container with explicit dimensions tells the browser "this space is reserved." When the image loads, it fills the reserved space without shifting anything else.
For ads and third-party embeds (YouTube, Twitter, maps), do the same—wrap them in a container with known dimensions:
export default function VideoEmbed() {
return (
<div style={{ position: 'relative', paddingBottom: '56.25%', height: 0 }}>
<iframe
src="https://www.youtube.com/embed/..."
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
}}
frameBorder="0"
allowFullScreen
/>
</div>
);
}The padding-bottom trick (56.25% for 16:9 video) reserves space before the iframe loads, preventing the jump.
Use next/font for Web Fonts
If you're loading Google Fonts without optimization, the browser waits for the font file to download, then re-renders text. That shift counts as CLS. next/font downloads fonts at build time and serves them locally, eliminating the delay:
import { Inter, Playfair_Display } from 'next/font/google';
const inter = Inter({ subsets: ['latin'] });
const playfair = Playfair_Display({ weight: '700', subsets: ['latin'] });
export default function Layout({ children }) {
return (
<html className={inter.className}>
<body>
<h1 className={playfair.className}>My Site</h1>
{children}
</body>
</html>
);
}Fonts are preloaded and cached, so no invisible text while waiting, and no re-render when they arrive.
Real-world impact: Implementing next/font on a travel site reduced CLS from 0.18 to 0.02.
INP — Interaction to Next Paint
INP measures how long it takes for a page to respond to a user's click or tap. Ideally under 200 ms. This one is about JavaScript efficiency, not assets. When a user clicks a button, their browser has to:
- Run your event handler (the "input" phase)
- Render the next frame (the "processing" phase)
- Paint it to screen (the "presentation" phase)
If any of those takes too long, the page feels unresponsive. I debugged a trading platform where submitting an order took 800 ms. The reason: we were processing the entire order pipeline synchronously in the click handler, including validation, API calls, and state updates. The UI froze for a full second.
Break Up Long Tasks
If your event handler does heavy work, chunk it. Use requestIdleCallback or setTimeout to defer non-critical work until the browser has time:
async function handleSubmit(formData) {
// Critical: validate and send API request
const response = await submitOrder(formData);
setOrderId(response.id);
// Non-critical: log, track, update cache
setTimeout(() => {
logOrderEvent(response.id);
updateLocalCache(response);
sendAnalytics();
}, 0);
}
export default function OrderForm() {
return (
<form onSubmit={(e) => {
e.preventDefault();
handleSubmit(new FormData(e.target));
}}>
{/* form fields */}
</form>
);
}The browser now renders the success message immediately, then handles logging in the background. The user sees instant feedback.
Reduce Client JavaScript
The less JavaScript you ship, the faster it parses and runs. On a ride-hailing app, we were shipping 450 KB of JavaScript. Half of it was unused dependencies and dead code. After tree-shaking and removing unused packages, we cut it to 200 KB. INP improved by 60 ms just from parsing faster.
Check your bundle:
// next.config.js
const { withPlausibleProxy } = require('next-plausible');
module.exports = withPlausibleProxy()({
webpack: (config, { dev }) => {
if (!dev) {
config.optimization.usedExports = true;
}
return config;
},
});Better yet, use dynamic imports to code-split non-critical components:
import dynamic from 'next/dynamic';
const HeavyChart = dynamic(() => import('./HeavyChart'), {
loading: () => <p>Loading chart...</p>,
ssr: false,
});
export default function Dashboard() {
return (
<>
<h1>Dashboard</h1>
<HeavyChart />
</>
);
}The chart's JavaScript loads only when needed, not on page load.
Defer Non-Critical Work
Analytics, error tracking, and secondary features can wait. Use useEffect with an empty dependency array to load them after the page is interactive:
useEffect(() => {
// This runs after render, not blocking INP
import('firebase/analytics').then((mod) => {
mod.initializeAnalytics();
});
}, []);Real-world impact: On a SaaS platform, deferring analytics reduced INP from 280 ms to 140 ms.
Measure Real Users — useReportWebVitals
You can't fix what you don't measure. Synthetic metrics (Lighthouse) are useful, but field data from real users is what matters. Next.js provides a built-in hook to send Core Web Vitals to your analytics:
// pages/_app.tsx
import { useReportWebVitals } from 'next/web-vitals';
function MyApp({ Component, pageProps }) {
useReportWebVitals((metric) => {
// Send to your analytics service
console.log(`${metric.name}: ${metric.value}`);
// Example: send to Vercel Analytics
if (window.gtag) {
window.gtag.event(metric.name, {
value: Math.round(metric.value),
event_category: 'web-vital',
event_label: metric.id,
non_interaction: true,
});
}
});
return <Component {...pageProps} />;
}
export default MyApp;Or integrate with a dedicated service like Sentry, LogRocket, or Vercel's built-in analytics. This data tells you:
- Which pages are slow for which users
- Whether your fixes actually worked in production
- Which device/browser/network combinations struggle most
I've seen projects fix LCP in the lab, ship it, and discover in field data that one specific image still wasn't optimized. Field metrics caught it immediately.
Common Mistakes
- Lazy-loading hero images.
priorityexists for a reason. If it's above the fold, use it. - Not measuring real users. Your Lighthouse score of 90 means nothing if field LCP is 3.5 s. Ship
useReportWebVitalsfrom day one. - Ignoring third-party scripts. Analytics, ads, and chat widgets add bloat. Load them async or defer them.
- Reserving space only for images. If your layout shifts because a sidebar loads late, that's CLS too. Reserve space for everything dynamic.
- Optimizing for the wrong network. Test on slow 4G, not your gigabit connection. Throttle in Chrome DevTools to simulate real conditions.
Key Takeaways
- LCP: Optimize hero images, server-render above-the-fold content, use a CDN. Aim for under 2.5 seconds.
- CLS: Reserve space for dynamic content with known dimensions, use
next/fontto eliminate font shifts. Aim for under 0.1. - INP: Break up long tasks, defer non-critical work, reduce JavaScript bundle size. Aim for under 200 ms.
- Field data wins. Ship
useReportWebVitalsto track real users. Lab metrics are a starting point, not the finish line. - It's iterative. Fix the biggest bottleneck first, measure, then move to the next. You don't need perfect scores, you need green vitals in production.






Comments
Loading comments…