Web Performance Optimization 2026 — Core Web Vitals, LCP, CLS, and INP Guide
Advertisement
Introduction
Why This Matters
Google uses Core Web Vitals as a ranking signal. In 2026, INP (Interaction to Next Paint) replaced FID as the interactivity metric. Sites that miss the "Good" thresholds for LCP, CLS, and INP rank lower and convert worse — performance is a business metric.
Core Web Vitals Thresholds
| Metric | Good | Needs Improvement | Poor |
|---|---|---|---|
| LCP (Largest Contentful Paint) | <= 2.5s | <= 4s | > 4s |
| CLS (Cumulative Layout Shift) | <= 0.1 | <= 0.25 | > 0.25 |
| INP (Interaction to Next Paint) | <= 200ms | <= 500ms | > 500ms |
Measuring Performance
Start with measurement before optimizing:
# Install web-vitals library
npm install web-vitals// src/lib/vitals.ts
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals'
function sendToAnalytics(metric: { name: string; value: number; rating: string }) {
fetch('/api/vitals', {
method: 'POST',
body: JSON.stringify(metric),
headers: { 'Content-Type': 'application/json' },
})
}
export function reportWebVitals() {
onCLS(sendToAnalytics)
onINP(sendToAnalytics)
onLCP(sendToAnalytics)
onFCP(sendToAnalytics)
onTTFB(sendToAnalytics)
}// app/layout.tsx (Next.js)
'use client'
import { useEffect } from 'react'
import { reportWebVitals } from '@/lib/vitals'
export function VitalsReporter() {
useEffect(() => { reportWebVitals() }, [])
return null
}Improving LCP
LCP is most often the hero image or largest heading. Target: under 2.5 seconds.
// 1. Preload the LCP image
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<head>
<link
rel="preload"
href="/images/hero.webp"
as="image"
fetchPriority="high"
/>
</head>
<body>{children}</body>
</html>
)
}
// 2. Use next/image with priority
import Image from 'next/image'
export function Hero() {
return (
<Image
src="/images/hero.webp"
alt="Hero"
width={1200}
height={630}
priority // adds fetchpriority="high" + preload link
sizes="100vw"
/>
)
}
// 3. Use a CDN with Cache-Control: public, max-age=31536000, immutableEliminating CLS
Layout shift happens when resources load without reserved dimensions:
// Always set width/height on images
<Image src={img} alt="" width={800} height={450} />
// Reserve space for dynamic content
function SkeletonCard() {
return (
<div className="h-48 bg-gray-100 rounded-lg animate-pulse" aria-hidden />
)
}
// Avoid inserting content above existing content
// BAD: inserting a banner at the top of the page after load
// GOOD: reserve the banner slot with min-height in the HTML
// Use CSS aspect-ratio for responsive containers/* Prevent font FOUT causing layout shift */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter.woff2') format('woff2');
font-display: swap; /* or 'optional' for zero CLS */
}
/* Size-adjust compensates for font metric differences */Improving INP
INP measures the worst interaction delay. Fix long tasks first:
// Break up long synchronous work with scheduler.postTask
async function processLargeList(items: Item[]) {
const CHUNK_SIZE = 50
const results: Result[] = []
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
const chunk = items.slice(i, i + CHUNK_SIZE)
results.push(...processChunk(chunk))
// Yield to the browser between chunks
await scheduler.postTask(() => {}, { priority: 'background' })
}
return results
}
// Use startTransition to defer non-urgent state updates (React)
import { useTransition } from 'react'
function SearchInput() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [isPending, startTransition] = useTransition()
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value)
startTransition(() => {
setResults(search(e.target.value))
})
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending ? <Spinner /> : <Results data={results} />}
</>
)
}Bundle Optimization
// next.config.ts
import { NextConfig } from 'next'
import bundleAnalyzer from '@next/bundle-analyzer'
const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
})
export default withBundleAnalyzer({
experimental: {
optimizePackageImports: ['lodash', 'date-fns', '@radix-ui/react-icons'],
},
images: {
formats: ['image/avif', 'image/webp'],
},
} satisfies NextConfig)# Analyze the bundle
ANALYZE=true npm run buildCommon Mistakes
- Not setting
priorityon the LCP image — it loads lazily by default in next/image - Loading Google Fonts with a
<link>instead ofnext/font— causes CLS and extra network round trips - Adding event listeners in
useEffectwithout cleanup — causes memory leaks in long sessions - Importing an entire utility library (
import _ from 'lodash') instead of specific functions - Not measuring INP in the field — lab tools like Lighthouse do not reliably capture interaction delays
Best Practices
- Set
priorityon the LCP image element and remove it from all others - Use
next/fontto self-host fonts and eliminate layout shift from FOUT - Profile INP with Chrome DevTools Performance panel on a real device, not a laptop
- Set
Cache-Control: public, max-age=31536000, immutableon all hashed static assets - Run
ANALYZE=true npm run buildmonthly to catch bundle bloat before it ships
Key Takeaways
- INP replaced FID as the Core Web Vitals interactivity metric — target under 200ms
- LCP under 2.5s requires a preloaded, priority-fetched, CDN-served hero image
- CLS under 0.1 requires explicit dimensions on all images and reserved space for dynamic content
scheduler.postTaskorsetTimeoutyields between long tasks to keep INP low- React
startTransitionmarks state updates as non-urgent, keeping input response fast - Bundle analysis with
@next/bundle-analyzerreveals which dependencies dominate the JS bundle - Field data from Real User Monitoring (RUM) is more accurate than Lighthouse for INP
font-display: optionaleliminates FOUT at the cost of a first-load blank period for custom fonts
Advertisement