Why Core Web Vitals Are Non-Optional in 2026
Google uses Core Web Vitals as a ranking signal in search. Beyond SEO, they are genuine measures of user experience — slow pages have higher bounce rates, lower conversion rates, and reduced engagement. A 1-second delay in page load time has been measured at a 7% reduction in conversions. This guide covers each vital with its threshold, what causes violations, and how to fix them.
LCP: Largest Contentful Paint
LCP measures how long until the largest visible content element (hero image, heading, video poster) finishes rendering. It is the user's perception of "when did the page load?"
Thresholds: Good ≤ 2.5s | Needs Improvement 2.5–4.0s | Poor > 4.0s
Top causes and fixes:
- Slow server response (TTFB) — Implement server-side caching, use a CDN, optimize database queries on the page's initial data fetch. In Next.js, use
generateStaticParamsandrevalidatefor ISR to serve from CDN cache. - Render-blocking resources — Defer non-critical CSS and JavaScript. Use
deferorasyncon script tags. In Next.js App Router, scripts added via<Script strategy="lazyOnload">are deferred automatically. - Slow LCP image — The most common cause. Preload the hero image and use modern formats.
// Next.js: priority prop preloads the LCP image
import Image from 'next/image'
export default function Hero() {
return (
<Image
src="/hero.webp"
alt="Hero image"
width={1200}
height={600}
priority // ← Preloads with <link rel="preload">, do NOT use on below-the-fold images
sizes="(max-width: 768px) 100vw, 1200px"
quality={85}
/>
)
}
// Convert images to WebP/AVIF (50–80% smaller than JPEG at same quality)
// Next.js Image component handles this automatically when served from Vercel
INP: Interaction to Next Paint
INP replaced FID (First Input Delay) in March 2024. It measures the latency of all user interactions on a page — clicks, taps, key presses — from when the user interacts to when the browser renders the next frame.
Thresholds: Good ≤ 200ms | Needs Improvement 200–500ms | Poor > 500ms
Main causes: Long-running JavaScript on the main thread. The browser cannot render new frames while JavaScript is executing. Key fixes:
- Break up long tasks — Any task over 50ms blocks the main thread. Use
scheduler.yield()(orsetTimeout(0, fn)as a polyfill) to yield back to the browser between chunks of work. - Defer non-critical work — Move analytics, logging, and non-visible computations to after the first user interaction.
// Yield to the browser between heavy computation chunks
async function processLargeDataset(items: Item[]) {
const results: Result[] = []
const CHUNK_SIZE = 100
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
const chunk = items.slice(i, i + CHUNK_SIZE)
results.push(...chunk.map(expensiveTransform))
// Yield to browser — allows rendering and input processing between chunks
if (i + CHUNK_SIZE < items.length) {
await new Promise(resolve => setTimeout(resolve, 0))
}
}
return results
}
CLS: Cumulative Layout Shift
CLS measures visual stability — how much content jumps around as the page loads. A score of 0 means no unexpected layout shifts. Common causes: images without dimensions, late-loading ads or embeds, web fonts loading and swapping.
Thresholds: Good ≤ 0.1 | Needs Improvement 0.1–0.25 | Poor > 0.25
// Always set explicit width and height on images to reserve space
// Without dimensions: browser doesn't know how much space to reserve → layout shift
<img src="/photo.jpg" width={800} height={600} alt="Photo" /> // ✓ Reserves space
<img src="/photo.jpg" alt="Photo" /> // ✗ Layout shift when image loads
// For responsive images, use aspect-ratio CSS
<div style={{ aspectRatio: '4/3', width: '100%' }}>
<img src="/photo.jpg" style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt="" />
</div>
// Fonts: use font-display: swap or optional to prevent invisible text
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: optional; /* "optional" is best for CLS — no swap if font isn't cached */
}
// In Next.js, use next/font — it zero-CLS loads fonts with CSS variables
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'], display: 'swap' })
Code Splitting with Dynamic Imports
Code splitting splits your JavaScript bundle into smaller chunks that are loaded on demand. In Next.js App Router, routes are automatically code-split. Within a page, use dynamic() to split heavy components:
import dynamic from 'next/dynamic'
// Heavy component loaded only when needed (e.g., a rich text editor, chart library)
const RichEditor = dynamic(() => import('@/components/RichEditor'), {
loading: () => <div className="h-64 bg-gray-100 animate-pulse rounded" />,
ssr: false, // Some libraries require browser APIs — disable SSR for those
})
// Only render (and load) when user clicks "Edit"
function ArticleEditor() {
const [editing, setEditing] = useState(false)
return editing
? <RichEditor /> // Loaded on first render — not in initial bundle
: <button onClick={() => setEditing(true)}>Edit</button>
}
// Lazy load React components (React 18+ suspense)
import { lazy, Suspense } from 'react'
const Chart = lazy(() => import('./Chart'))
function Dashboard() {
return (
<Suspense fallback={<div>Loading chart...</div>}>
<Chart />
</Suspense>
)
}
Bundle Analysis
# Install bundle analyzer for Next.js
npm install @next/bundle-analyzer
# next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer({})
# Run analysis — opens an interactive treemap of your bundle
ANALYZE=true npm run build
# What to look for:
# - Large libraries: moment.js (→ replace with date-fns or dayjs)
# - Duplicate packages (two versions of lodash)
# - Large libraries loaded on every page but only needed on one
# - node_modules that should be in devDependencies
React.memo, useMemo, and useCallback: When to Actually Use Them
These are optimization tools, not best practices. Using them everywhere adds code complexity and can actually slow things down (every memoization has a cost). Apply them only when you have measured a performance problem.
// React.memo — prevents a component from re-rendering if props haven't changed
// USE WHEN: The component renders frequently, renders are expensive, and props are often the same
const ExpensiveList = React.memo(function ExpensiveList({ items }: { items: Item[] }) {
return <ul>{items.map(i => <li key={i.id}>{i.name}</li>)}</ul>
})
// SKIP IT WHEN: The component is cheap to render, or props always change
// useMemo — memoize an expensive computation
// USE WHEN: The computation is genuinely slow (e.g., filtering 10,000 items)
const filteredItems = useMemo(
() => items.filter(i => i.name.toLowerCase().includes(searchQuery.toLowerCase())),
[items, searchQuery] // Only recompute when items or searchQuery changes
)
// SKIP IT WHEN: The computation is fast. Don't wrap every derived value in useMemo.
// useCallback — memoize a function reference
// USE WHEN: You pass a function to a React.memo component, or as a useEffect dependency
const handleDelete = useCallback((id: string) => {
setItems(prev => prev.filter(i => i.id !== id))
}, []) // Stable reference — won't cause ExpensiveList to re-render unnecessarily
// SKIP IT WHEN: The function is only used in the current component
Caching Strategies
# Cache-Control header strategy:
# Immutable static assets (content-hashed filenames):
Cache-Control: public, max-age=31536000, immutable
# e.g., /static/main.a3f9c2.js — never changes, cache forever
# HTML pages — always validate before serving:
Cache-Control: no-cache
# Forces browser to revalidate with server (returns 304 Not Modified if unchanged)
# API responses — short cache for freshness vs performance:
Cache-Control: public, max-age=60, stale-while-revalidate=300
# Serve cached version for 60s, then serve stale while revalidating in background
# User-specific data — prevent cross-user cache sharing:
Cache-Control: private, no-store
# Never cache in shared CDN caches