Fixing Next.js Core Web Vitals: LCP, FID, and CLS Issues
Your Next.js application scores perfectly in Lighthouse during development, but production metrics tell a different story. Real users report slow loading times,...
Osmoto Team
Senior Software Engineer

Your Next.js application scores perfectly in Lighthouse during development, but production metrics tell a different story. Real users report slow loading times, janky interactions, and layout shifts that make your site feel broken. The culprit? Core Web Vitals issues that only surface under real-world conditions with actual network latency, device constraints, and user behavior patterns.
Core Web Vitals directly impact both user experience and search rankings. Google uses these metrics as ranking factors, and more importantly, poor scores correlate with higher bounce rates and lower conversion rates. A 100ms improvement in Largest Contentful Paint (LCP) can increase conversion rates by up to 8%, while reducing Cumulative Layout Shift (CLS) eliminates the frustrating experience of users accidentally clicking wrong elements due to unexpected page shifts.
This guide provides a systematic approach to diagnosing and fixing the three Core Web Vitals in Next.js applications: LCP (loading performance), First Input Delay/Interaction to Next Paint (interactivity), and CLS (visual stability). We'll cover both App Router and Pages Router implementations, focusing on production-ready solutions that work at scale.
Understanding Core Web Vitals in Next.js Context
Largest Contentful Paint (LCP) - Loading Performance
LCP measures when the largest content element becomes visible to users. In Next.js applications, this is typically an image, video, or large text block. The target is under 2.5 seconds, but modern users expect sub-second loading.
Common LCP elements in Next.js apps:
- Hero images loaded with
next/image - Large text blocks from CMS content
- Video elements or embedded media
- Product images in e-commerce applications
Next.js provides several built-in optimizations for LCP, but they require proper configuration. The framework's automatic code splitting can actually hurt LCP if critical resources are split into separate chunks that load sequentially rather than in parallel.
First Input Delay (FID) and Interaction to Next Paint (INP)
Google is transitioning from FID to INP as the interactivity metric. FID measures the delay between user interaction and browser response, while INP measures the time from interaction to visual update. Next.js applications often struggle with INP due to:
- Large JavaScript bundles blocking the main thread
- Hydration delays in server-side rendered components
- Expensive re-renders triggered by user interactions
- Third-party scripts interfering with main thread execution
Cumulative Layout Shift (CLS) - Visual Stability
CLS quantifies unexpected layout shifts during page loading. Next.js applications commonly experience CLS from:
- Images loading without proper dimensions
- Fonts swapping after initial render
- Dynamic content injection (ads, social widgets)
- CSS-in-JS libraries causing style recalculation
The target CLS score is under 0.1, with 0.0 being ideal for critical user flows like checkout processes.
Diagnosing Core Web Vitals Issues
Setting Up Proper Measurement
Real User Monitoring (RUM) provides more accurate data than synthetic testing. Implement Web Vitals measurement in your Next.js app:
// lib/web-vitals.ts import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals'; function sendToAnalytics(metric: any) { // Send to your analytics service if (typeof window !== 'undefined') { // Example: Google Analytics 4 gtag('event', metric.name, { event_category: 'Web Vitals', value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value), event_label: metric.id, non_interaction: true, }); } } export function reportWebVitals() { getCLS(sendToAnalytics); getFID(sendToAnalytics); getFCP(sendToAnalytics); getLCP(sendToAnalytics); getTTFB(sendToAnalytics); }
For App Router, add to your root layout:
// app/layout.tsx 'use client'; import { useEffect } from 'react'; import { reportWebVitals } from '../lib/web-vitals'; export default function RootLayout({ children, }: { children: React.ReactNode; }) { useEffect(() => { reportWebVitals(); }, []); return ( <html lang="en"> <body>{children}</body> </html> ); }
Identifying LCP Elements
Use the Web Vitals Chrome extension or browser DevTools to identify your LCP element. Common issues and their indicators:
Image-based LCP problems:
- LCP element is an unoptimized image
- Image loads after other resources complete
- Missing
priorityprop on above-the-fold images
Text-based LCP problems:
- Large text blocks using web fonts
- Font swap causing layout recalculation
- Text content loaded via client-side JavaScript
Resource loading issues:
- LCP element depends on JavaScript execution
- Critical resources blocked by non-critical ones
- Missing preload hints for essential assets
Fixing LCP Issues
Optimizing Images for LCP
The next/image component provides automatic optimization, but requires proper configuration for LCP improvements:
// components/HeroImage.tsx import Image from 'next/image'; export function HeroImage() { return ( <Image src="/hero-image.jpg" alt="Hero image" width={1200} height={600} priority // Critical for above-the-fold images placeholder="blur" blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..." // Inline blur sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" style={{ width: '100%', height: 'auto', }} /> ); }
Key optimizations:
priority={true}prevents lazy loading for LCP images- Proper
sizesattribute ensures correct image variant selection placeholder="blur"provides immediate visual feedback- Explicit width/height prevents layout shift
Preloading Critical Resources
Use Next.js built-in preloading for critical assets:
// app/head.tsx or pages/_document.tsx export default function Head() { return ( <> <link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossOrigin="anonymous" /> <link rel="preload" href="/hero-image.jpg" as="image" /> </> ); }
For dynamic imports that affect LCP, consider using next/dynamic with preloading:
// components/CriticalComponent.tsx import dynamic from 'next/dynamic'; const CriticalComponent = dynamic(() => import('./HeavyComponent'), { loading: () => <div>Loading...</div>, ssr: true, // Ensure server-side rendering for LCP content });
Font Optimization for LCP
Font loading significantly impacts LCP when text is the largest contentful element. Configure optimal font loading:
/* globals.css */ @font-face { font-family: 'Inter'; font-style: normal; font-weight: 100 900; font-display: swap; /* Prevents invisible text during font load */ src: url('/fonts/inter-var.woff2') format('woff2'); }
Use the next/font optimization for Google Fonts:
// app/layout.tsx import { Inter } from 'next/font/google'; const inter = Inter({ subsets: ['latin'], display: 'swap', preload: true, }); export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <html lang="en" className={inter.className}> <body>{children}</body> </html> ); }
Fixing Interactivity Issues (FID/INP)
Reducing JavaScript Bundle Size
Large JavaScript bundles block the main thread, causing poor FID/INP scores. Analyze your bundle:
# Install bundle analyzer npm install --save-dev @next/bundle-analyzer # Add to next.config.js const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true', }); module.exports = withBundleAnalyzer({ // your config }); # Generate bundle analysis ANALYZE=true npm run build
Common optimization strategies:
Code splitting at component level:
// Instead of importing everything upfront import { HeavyChart, ExpensiveModal, RarelyUsedFeature } from './components'; // Split by usage patterns const HeavyChart = dynamic(() => import('./HeavyChart')); const ExpensiveModal = dynamic(() => import('./ExpensiveModal'));
Tree shaking third-party libraries:
// Bad: imports entire library import _ from 'lodash'; // Good: import only needed functions import { debounce, throttle } from 'lodash'; // Better: use specific imports import debounce from 'lodash/debounce';
Optimizing Hydration Performance
Hydration delays directly impact INP. Use selective hydration for better performance:
// components/InteractiveSection.tsx import { useState, useEffect } from 'react'; import dynamic from 'next/dynamic'; // Only hydrate interactive components when needed const InteractiveWidget = dynamic(() => import('./InteractiveWidget'), { ssr: false, // Skip SSR for client-only interactivity }); export function InteractiveSection() { const [isClient, setIsClient] = useState(false); useEffect(() => { setIsClient(true); }, []); if (!isClient) { return <div>Loading interactive content...</div>; } return <InteractiveWidget />; }
For App Router, use the use client directive strategically:
// app/dashboard/page.tsx - Server Component by default import { Suspense } from 'react'; import StaticContent from './StaticContent'; import InteractiveChart from './InteractiveChart'; export default function DashboardPage() { return ( <div> <StaticContent /> {/* Rendered on server */} <Suspense fallback={<div>Loading chart...</div>}> <InteractiveChart /> {/* Client component with 'use client' */} </Suspense> </div> ); }
Third-Party Script Optimization
Third-party scripts often cause INP issues. Use Next.js Script component for optimal loading:
// app/layout.tsx import Script from 'next/script'; export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <html lang="en"> <body> {children} <Script src="https://www.googletagmanager.com/gtag/js?id=GA_TRACKING_ID" strategy="afterInteractive" // Load after page is interactive /> <Script id="google-analytics"> {` window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date()); gtag('config', 'GA_TRACKING_ID'); `} </Script> </body> </html> ); }
Script loading strategies:
beforeInteractive: Critical scripts (rare)afterInteractive: Analytics, social widgetslazyOnload: Non-critical scriptsworker: Experimental Partytown integration
Fixing Cumulative Layout Shift (CLS)
Preventing Image Layout Shifts
Always specify image dimensions to prevent layout shifts:
// Bad: No dimensions specified <Image src="/product.jpg" alt="Product" /> // Good: Explicit dimensions <Image src="/product.jpg" alt="Product" width={400} height={300} style={{ width: '100%', height: 'auto', }} /> // Better: Responsive with aspect ratio <div style={{ aspectRatio: '4/3', position: 'relative' }}> <Image src="/product.jpg" alt="Product" fill style={{ objectFit: 'cover' }} /> </div>
Font Loading Without Layout Shift
Prevent font swap layout shifts using size-adjust:
/* globals.css */ @font-face { font-family: 'CustomFont'; src: url('/fonts/custom-font.woff2') format('woff2'); font-display: swap; size-adjust: 100.06%; /* Adjust to match fallback font metrics */ } /* Fallback font with similar metrics */ .font-loading { font-family: 'CustomFont', Arial, sans-serif; }
Use the next/font automatic size adjustment:
// app/layout.tsx import { Inter } from 'next/font/google'; const inter = Inter({ subsets: ['latin'], adjustFontFallback: true, // Automatically adjusts fallback metrics });
Dynamic Content Without Layout Shift
Reserve space for dynamic content to prevent shifts:
// components/DynamicContent.tsx import { useState, useEffect } from 'react'; export function DynamicContent() { const [content, setContent] = useState(null); const [isLoading, setIsLoading] = useState(true); useEffect(() => { fetchContent().then((data) => { setContent(data); setIsLoading(false); }); }, []); // Reserve space with skeleton loader if (isLoading) { return ( <div className="space-y-4"> <div className="h-6 bg-gray-200 rounded animate-pulse" /> <div className="h-4 bg-gray-200 rounded animate-pulse w-3/4" /> <div className="h-4 bg-gray-200 rounded animate-pulse w-1/2" /> </div> ); } return <div>{content}</div>; }
Advanced Optimization Techniques
Server Components for Better Performance
App Router's Server Components reduce JavaScript bundle size and improve all Core Web Vitals:
// app/products/page.tsx - Server Component import { getProducts } from '../lib/data'; import ProductCard from './ProductCard'; export default async function ProductsPage() { const products = await getProducts(); // Runs on server return ( <div className="grid grid-cols-3 gap-4"> {products.map((product) => ( <ProductCard key={product.id} product={product} /> ))} </div> ); } // app/products/ProductCard.tsx - Server Component export default function ProductCard({ product }) { return ( <div className="border rounded p-4"> <h3>{product.name}</h3> <p>{product.description}</p> <AddToCartButton productId={product.id} /> {/* Client Component */} </div> ); }
Streaming for Progressive Loading
Use React 18 Suspense with streaming to improve perceived performance:
// app/dashboard/page.tsx import { Suspense } from 'react'; import QuickStats from './QuickStats'; import DetailedChart from './DetailedChart'; import RecentActivity from './RecentActivity'; export default function Dashboard() { return ( <div className="space-y-6"> <QuickStats /> {/* Fast-loading component */} <Suspense fallback={<ChartSkeleton />}> <DetailedChart /> {/* Slower component */} </Suspense> <Suspense fallback={<ActivitySkeleton />}> <RecentActivity /> {/* Another slow component */} </Suspense> </div> ); }
Critical CSS Inlining
For pages with custom styling that affects LCP, consider inlining critical CSS:
// next.config.js module.exports = { experimental: { optimizeCss: true, // Enables CSS optimization }, compiler: { removeConsole: process.env.NODE_ENV === 'production', }, };
Manual critical CSS extraction:
// lib/critical-css.ts export function getCriticalCSS(pageName: string) { // Extract critical CSS for specific pages const criticalStyles = { home: ` .hero { min-height: 60vh; } .hero-image { aspect-ratio: 16/9; } `, product: ` .product-grid { display: grid; grid-template-columns: 1fr 1fr; } .product-image { aspect-ratio: 1/1; } `, }; return criticalStyles[pageName] || ''; }
Common Pitfalls and Edge Cases
Hydration Mismatches Affecting CLS
Server-client content mismatches cause layout shifts during hydration:
// Bad: Different content on server vs client function UserGreeting() { const [user, setUser] = useState(null); useEffect(() => { setUser(getCurrentUser()); }, []); // This causes a layout shift when user loads return <div>{user ? `Hello, ${user.name}!` : 'Loading...'}</div>; } // Good: Consistent server/client rendering function UserGreeting() { const [user, setUser] = useState(null); const [isClient, setIsClient] = useState(false); useEffect(() => { setIsClient(true); setUser(getCurrentUser()); }, []); // Reserve space to prevent layout shift return ( <div className="min-h-[2rem] flex items-center"> {isClient && user ? `Hello, ${user.name}!` : ''} </div> ); }
CSS-in-JS Performance Impact
CSS-in-JS libraries can hurt all three Core Web Vitals. If you must use them, optimize their usage:
// Bad: Runtime style generation const StyledButton = styled.button` background: ${props => props.primary ? 'blue' : 'gray'}; padding: ${props => props.size === 'large' ? '12px 24px' : '8px 16px'}; `; // Better: Pre-computed styles const buttonStyles = { base: 'px-4 py-2 rounded', primary: 'bg-blue-500 text-white', secondary: 'bg-gray-500 text-white', large: 'px-6 py-3', small: 'px-2 py-1', }; function Button({ primary, size, children }) { const classes = [ buttonStyles.base, primary ? buttonStyles.primary : buttonStyles.secondary, buttonStyles[size] || '', ].join(' '); return <button className={classes}>{children}</button>; }
Third-Party Widget Integration
Social media widgets, chat systems, and ads commonly cause Core Web Vitals issues:
// components/SocialWidget.tsx import { useEffect, useRef, useState } from 'react'; export function TwitterEmbed({ tweetId }: { tweetId: string }) { const containerRef = useRef<HTMLDivElement>(null); const [isVisible, setIsVisible] = useState(false); useEffect(() => { const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { setIsVisible(true); observer.disconnect(); } }, { threshold: 0.1 } ); if (containerRef.current) { observer.observe(containerRef.current); } return () => observer.disconnect(); }, []); return ( <div ref={containerRef} className="min-h-[200px] border rounded" // Reserve space > {isVisible && ( <blockquote className="twitter-tweet"> <a href={`https://twitter.com/user/status/${tweetId}`}> Loading tweet... </a> </blockquote> )} </div> ); }
Best Practices Summary
LCP Optimization Checklist
- Add
priorityprop to above-the-fold images - Preload critical resources (fonts, hero images)
- Optimize font loading with
font-display: swap - Use Server Components to reduce JavaScript bundle size
- Implement proper image sizing and responsive images
- Minimize render-blocking resources
FID/INP Optimization Checklist
- Analyze and reduce JavaScript bundle size
- Use code splitting for non-critical components
- Optimize third-party script loading
- Implement selective hydration
- Use Server Components where possible
- Debounce expensive operations
CLS Optimization Checklist
- Specify dimensions for all images and media
- Reserve space for dynamic content
- Optimize font loading to prevent swaps
- Use skeleton loaders for loading states
- Test with slow network conditions
- Avoid inserting content above existing content
Monitoring and Maintenance
- Implement Real User Monitoring (RUM)
- Set up Core Web Vitals alerts
- Regular performance audits
- Test on various devices and networks
- Monitor after deployments
Conclusion
Fixing Core Web Vitals in Next.js requires a systematic approach that addresses each metric's root causes. LCP improvements come from optimizing critical resource loading and reducing render-blocking JavaScript. FID/INP optimization focuses on reducing main thread work and improving interactivity. CLS fixes prevent unexpected layout shifts through proper space reservation and optimized loading strategies.
The key is measuring real user performance, not just synthetic lab scores. Implement proper monitoring, test optimizations under realistic conditions, and prioritize fixes based on actual user impact. Remember that Core Web Vitals optimization is an ongoing process—new features, content, and third-party integrations can introduce regressions.
If your Next.js application needs comprehensive performance optimization, including Core Web Vitals improvements, App Router migration, and advanced optimization techniques, our Next.js optimization service provides expert analysis and implementation. We've helped numerous applications achieve consistent Core Web Vitals scores in the "Good" range while maintaining feature richness and development velocity.
