Web Vitals & Lighthouse 100: Practical Optimization Guide 2026
Lighthouse 100 on a blank page is meaningless. This guide targets a real Next.js production application — with dynamic data, authentication, images, and third-party scripts. These are specific changes, not generic "minify your CSS" advice.
The 2024-2026 Core Web Vitals Landscape
Google's Core Web Vitals updated in March 2024:
| Metric | Measures | Good | Needs Work | Poor |
|---|---|---|---|---|
| LCP | Largest Contentful Paint | ≤2.5s | 2.5-4s | >4s |
| INP | Interaction to Next Paint (replaced FID) | ≤200ms | 200-500ms | >500ms |
| CLS | Cumulative Layout Shift | ≤0.1 | 0.1-0.25 | >0.25 |
INP replaced FID (First Input Delay) as of March 2024. INP measures the worst interaction throughout the page lifecycle, not just the first one. This changes optimization strategy.
LCP: Largest Contentful Paint
LCP is almost always the hero image or above-the-fold heading. Find it:
# Simulate slow connection in Chrome DevTools
# Network → Slow 4G → Check LCP element in Lighthouse
Fix 1: Preload the LCP Image
// app/layout.tsx or page.tsx
import { headers } from 'next/headers';
// For Next.js Image component — priority prop preloads automatically
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority // ← generates <link rel="preload"> in <head>
quality={85}
sizes="100vw"
/>
Without priority, Next.js lazy-loads images. Your hero image arrives late = bad LCP.
Fix 2: Image Format and Size
// next.config.ts
const nextConfig = {
images: {
formats: ['image/avif', 'image/webp'], // AVIF first (30% smaller than WebP)
deviceSizes: [640, 750, 828, 1080, 1200, 1920],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
minimumCacheTTL: 60 * 60 * 24 * 7, // 7 days
},
};
Fix 3: Preconnect to External Resources
// app/layout.tsx <head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link rel="dns-prefetch" href="https://cdn.yourapi.com" />
Fix 4: TTFB (Time to First Byte)
TTFB directly impacts LCP. For Next.js:
// Export static pages where possible
export const dynamic = 'force-static'; // cached at CDN edge
// For dynamic pages — use Suspense to stream early
export default function Page() {
return (
<>
<StaticHeader /> {/* Sent immediately */}
<Suspense fallback={<Skeleton />}>
<DynamicContent /> {/* Streams when ready */}
</Suspense>
</>
);
}
Vercel/Cloudflare edge caching + streaming reduces TTFB to <100ms for most users.
INP: Interaction to Next Paint
INP is about every interaction, not just the first. Long tasks (>50ms on main thread) are the culprit.
Identify Long Tasks
// In your app — monitor long tasks
if (typeof PerformanceObserver !== 'undefined') {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
console.warn('Long task:', entry.duration, 'ms', entry);
}
}
});
observer.observe({ type: 'longtask', buffered: true });
}
Fix 1: Defer Non-Critical JavaScript
// Don't import heavy libraries synchronously
// ❌
import { Chart } from 'chart.js';
// ✅ Dynamic import — loads only when needed
const Chart = dynamic(() => import('chart.js'), { ssr: false });
// Or import on interaction
async function handleChartOpen() {
const { Chart } = await import('chart.js');
// Initialize chart
}
Fix 2: Break Up Synchronous Work
// ❌ Blocking: processes 10000 items synchronously
function processAllItems(items: Item[]): Result[] {
return items.map(expensiveTransform);
}
// ✅ Yield to browser between chunks
async function processInChunks(items: Item[], chunkSize = 100): Promise<Result[]> {
const results: Result[] = [];
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
results.push(...chunk.map(expensiveTransform));
// Yield to browser — allows user interactions to be processed
await new Promise(resolve => setTimeout(resolve, 0));
}
return results;
}
Fix 3: useTransition for Non-Urgent Updates
'use client';
import { useTransition, useState } from 'react';
export function SearchInput() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value); // Immediate — input stays responsive
startTransition(() => {
// Deferred — doesn't block input interactions
setResults(expensiveSearch(e.target.value));
});
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending ? <Spinner /> : <ResultsList results={results} />}
</>
);
}
CLS: Cumulative Layout Shift
Layout shifts happen when elements move after initial render. Common causes:
Fix 1: Always Set Image Dimensions
// ❌ No dimensions → browser doesn't reserve space → shift when image loads
<img src="/hero.jpg" alt="Hero" />
// ✅ Explicit dimensions → browser reserves space
<Image src="/hero.jpg" alt="Hero" width={1200} height={600} />
// ✅ Fill with aspect-ratio container
<div style={{ position: 'relative', aspectRatio: '16/9' }}>
<Image src="/hero.jpg" alt="Hero" fill style={{ objectFit: 'cover' }} />
</div>
Fix 2: Reserve Space for Dynamic Content
// ❌ Ads/widgets that appear after JS loads cause shift
<div id="ad-slot" />
// ✅ Reserve space before content loads
<div style={{ minHeight: 250, minWidth: 300 }}>
<AdSlot />
</div>
Fix 3: Font Loading Strategy
Fonts are a major CLS source. Text renders in system font, then jumps when web font loads.
// next/font — zero CLS, zero FOUT
import { Inter, JetBrains_Mono } from 'next/font/google';
const inter = Inter({
subsets: ['latin', 'cyrillic'],
display: 'swap', // show text immediately
preload: true,
variable: '--font-inter', // CSS variable for Tailwind
});
// layout.tsx
<html lang="ru" className={`${inter.variable}`}>
next/font self-hosts fonts, inlines the font-face CSS, and generates optimal font-display — eliminating FOUT entirely.
JavaScript Bundle Analysis
# Analyze bundle size
ANALYZE=true npm run build
# Or with @next/bundle-analyzer
npm install @next/bundle-analyzer
// next.config.ts
import withBundleAnalyzer from '@next/bundle-analyzer';
const withAnalyzer = withBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
});
export default withAnalyzer({
// your config
});
The bundle analyzer shows which packages are largest. Common offenders:
moment.js(67KB) → replace withdate-fns(tree-shakeable)lodash(70KB) → use individual importsimport debounce from 'lodash/debounce'@mui/material(full import) → use specific imports
Check What's in Your Chunks
# See largest imports
npx webpack-bundle-analyzer .next/static/chunks/
Third-Party Script Optimization
Google Analytics, Meta Pixel, and chat widgets are INP killers.
// ❌ Blocking third-party scripts
<script src="https://www.googletagmanager.com/gtm.js?id=GTM-XXX" />
// ✅ Next.js Script with afterInteractive strategy
import Script from 'next/script';
<Script
src="https://www.googletagmanager.com/gtm.js?id=GTM-XXX"
strategy="afterInteractive" // loads after page is interactive
/>
// For non-critical scripts (analytics, A/B testing):
<Script
src="https://cdn.example.com/widget.js"
strategy="lazyOnload" // loads during browser idle time
/>
Critical CSS
// next.config.ts
// Beasties (successor to critters) inlines critical CSS
// Install: npm install beasties
const nextConfig = {
experimental: {
optimizeCss: true, // uses beasties internally
},
};
This inlines above-the-fold CSS directly into HTML, eliminating render-blocking stylesheets.
Monitoring in Production
Lighthouse in DevTools is synthetic. Real user performance via Web Vitals API:
// lib/vitals.ts
import type { Metric } from 'web-vitals';
export function reportWebVitals(metric: Metric) {
const body = {
name: metric.name,
value: Math.round(metric.value),
rating: metric.rating,
id: metric.id,
navigationType: metric.navigationType,
};
// Send to your analytics endpoint
fetch('/api/vitals', {
method: 'POST',
body: JSON.stringify(body),
keepalive: true, // survives page unload
});
}
// app/layout.tsx
// Next.js built-in support
export function reportWebVitals(metric: NextWebVitalsMetric) {
if (process.env.NODE_ENV === 'production') {
sendToAnalytics(metric);
}
}
Track p75 (75th percentile), not average. Google measures at p75. If 25% of your users have poor CLS, you fail the CWV assessment regardless of your median.
Lighthouse 100 Checklist
Performance:
[ ] LCP image has priority prop
[ ] Images have explicit width/height or aspectRatio container
[ ] next/font for all web fonts
[ ] AVIF/WebP formats configured
[ ] Bundle analyzed — no accidentally large imports
[ ] Heavy components lazy loaded
[ ] Third-party scripts use afterInteractive/lazyOnload
[ ] Critical CSS inlined (beasties/optimizeCss)
[ ] Static pages exported where possible
Accessibility (affects Lighthouse score):
[ ] All images have meaningful alt text
[ ] Color contrast ratio ≥ 4.5:1 for normal text
[ ] Form inputs have associated labels
[ ] Interactive elements have accessible names
SEO:
[ ] Every page has unique title and meta description
[ ] robots.txt and sitemap.xml present
[ ] Canonical URLs configured
[ ] Structured data (Schema.org) where applicable
Best Practices:
[ ] HTTPS everywhere
[ ] No mixed content
[ ] No browser errors in console
[ ] Security headers configured
Aunimeda builds performance-optimized Next.js applications. Start a project.
See also: Next.js 15 Server Components deep dive, Clean Architecture in Node.js