Performance & Core Web Vitals
Core Web Vitals are Google's ranking signals measuring real user experience. Optimize for these three metrics.
Current Thresholds (2026)
| Metric | Good | Needs Improvement | Poor | What It Measures |
|---|---|---|---|---|
| LCP | < 2.5s | 2.5s - 4.0s | > 4.0s | Largest visible content painted |
| INP | < 200ms | 200ms - 500ms | > 500ms | Responsiveness to all interactions |
| CLS | < 0.1 | 0.1 - 0.25 | > 0.25 | Visual stability (layout shifts) |
INP replaced FID in March 2024. INP measures ALL interactions (clicks, taps, keyboard), not just the first one.
LCP Optimization (Largest Contentful Paint)
LCP measures how quickly the largest above-the-fold element renders.
Preload LCP Images
import Image from 'next/image'
// Mark the hero/banner image as priority — preloads it, disables lazy loading
<Image
src="/hero.jpg"
alt="Hero banner"
width={1200}
height={630}
priority // Critical: preloads for LCP
sizes="100vw" // Full-width image
/>
Only one image per page should have priority — the LCP element.
Optimize Image Formats
Configure AVIF (best compression) with WebP fallback:
// next.config.ts
const nextConfig = {
images: {
formats: ['image/avif', 'image/webp'],
remotePatterns: [
{ protocol: 'https', hostname: 'your-cms.com', pathname: '/assets/**' },
],
},
}
Responsive Images with sizes
Always provide sizes — without it, the browser downloads the largest image:
// Full-width hero
<Image src="/hero.jpg" alt="Hero" width={1920} height={1080} priority
sizes="100vw" />
// Grid of 3 columns on desktop, full-width on mobile
<Image src="/card.jpg" alt="Card" width={400} height={300}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" />
// Sidebar image
<Image src="/sidebar.jpg" alt="Sidebar" width={300} height={200}
sizes="(max-width: 768px) 100vw, 300px" />
Server-Side Render Above the Fold
Use Server Components for LCP content — no hydration delay:
// app/page.tsx — Server Component by default
export default async function Home() {
const hero = await getHeroContent() // Fetched on server, rendered in HTML
return (
<section>
<h1>{hero.title}</h1>
<Image src={hero.image} alt={hero.title} priority sizes="100vw"
width={1200} height={630} />
</section>
)
}
INP Optimization (Interaction to Next Paint)
INP measures responsiveness across ALL user interactions. Three phases: input delay + processing time + presentation delay.
Break Long Tasks
Any task >50ms blocks the main thread. Split heavy work:
'use client'
import { startTransition } from 'react'
function handleSearch(query: string) {
// Non-urgent: use startTransition to avoid blocking input
startTransition(() => {
setFilteredResults(filterLargeDataset(query))
})
}
Code Split Heavy Components
'use client'
import dynamic from 'next/dynamic'
// Load chart library only when needed — not in initial bundle
const Chart = dynamic(() => import('@/components/chart'), {
loading: () => <div className="h-64 animate-pulse bg-muted rounded" />,
ssr: false, // Chart needs browser APIs
})
Debounce Event Handlers
'use client'
import { useDeferredValue, useState } from 'react'
function SearchInput() {
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
return (
<>
<input value={query} => setQuery(e.target.value)} />
<SearchResults query={deferredQuery} />
</>
)
}
Reduce DOM Size
Target: < 1,500 nodes, depth < 32, < 60 children per parent. For long lists, use virtual scrolling:
'use client'
import { useVirtualizer } from '@tanstack/react-virtual'
function VirtualList({ items }: { items: string[] }) {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
})
return (
<div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div key={virtualItem.key} style={{
position: 'absolute', top: 0, left: 0, width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}>
{items[virtualItem.index]}
</div>
))}
</div>
</div>
)
}
CLS Optimization (Cumulative Layout Shift)
CLS measures visual stability — elements jumping around as the page loads.
Always Set Dimensions on Images
// Good — dimensions prevent layout shift
<Image src="/photo.jpg" alt="Photo" width={800} height={600} />
// Good — fill with sized parent
<div className="relative aspect-video">
<Image src="/photo.jpg" alt="Photo" fill sizes="100vw" />
</div>
// Bad — no dimensions → layout shift
<img src="/photo.jpg" alt="Photo" />
Use next/font to Prevent Font Shift
// app/layout.tsx
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap', // Show fallback immediately, swap when loaded
variable: '--font-sans',
})
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} antialiased`}>
<body>{children}</body>
</html>
)
}
Tailwind CSS integration:
/* globals.css */
@import 'tailwindcss';
@theme inline {
--font-sans: var(--font-sans);
}
next/font downloads fonts at build time and self-hosts them — zero external requests, zero FOUT (Flash of Unstyled Text).
Avoid Content Injection Above Fold
Never insert banners, cookie notices, or ads above existing content. Reserve space with CSS:
// Reserve space for a dynamic banner
<div className="min-h-12">
<Suspense fallback={<div className="h-12" />}>
<DynamicBanner />
</Suspense>
</div>
Caching & ISR
Incremental Static Regeneration
Serve static pages, revalidate in the background:
// app/blog/[slug]/page.tsx
export const revalidate = 3600 // Revalidate every hour
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = await getPost(slug)
return <article>{/* content */}</article>
}
On-Demand Revalidation
Revalidate when content changes (via webhook from CMS):
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'
export async function POST(request: Request) {
const { path, tag } = await request.json()
if (path) revalidatePath(path)
if (tag) revalidateTag(tag)
return Response.json({ revalidated: true })
}
Measurement Tools
| Tool | Data Type | Use For |
|---|---|---|
| PageSpeed Insights | Field + Lab | Quick check of any URL |
| Google Search Console | Field | Real user data over time |
| Lighthouse (DevTools) | Lab | Detailed development audit |
| Web Vitals Extension | Field | Real-time monitoring during dev |
next build --profile |
Lab | Identify slow build/render paths |
Quick Wins Checklist
| Action | Metric Impact | Effort |
|---|---|---|
Add priority to LCP image |
LCP -500ms+ | Low |
Add sizes to all images |
LCP, bandwidth | Low |
Use next/font |
CLS elimination | Low |
| Set image width/height | CLS elimination | Low |
dynamic() for heavy components |
INP, bundle size | Medium |
| Enable AVIF format | LCP, bandwidth | Low |
Use startTransition for non-urgent updates |
INP | Medium |
ISR with revalidate |
TTFB | Medium |