Introduction
Core Web Vitals are Google's official metrics for measuring user experience on the web. Understanding and optimizing these metrics is crucial for SEO rankings, user retention, and conversion rates. In this comprehensive guide, we'll explore practical strategies to achieve excellent Core Web Vitals scores.
What Are Core Web Vitals?
The Three Core Metrics
1. Largest Contentful Paint (LCP)
2. First Input Delay (FID) / Interaction to Next Paint (INP)
3. Cumulative Layout Shift (CLS)
Why They Matter
// Impact on business metrics
interface PerformanceImpact {
metricImprovement: string;
businessOutcome: string;
percentageChange: number;
}
const realWorldImpacts: PerformanceImpact[] = [
{
metricImprovement: 'LCP: 4.2s → 2.1s',
businessOutcome: 'Conversion rate increase',
percentageChange: 25
},
{
metricImprovement: 'FID: 300ms → 50ms',
businessOutcome: 'User engagement increase',
percentageChange: 40
},
{
metricImprovement: 'CLS: 0.25 → 0.05',
businessOutcome: 'Bounce rate decrease',
percentageChange: -30
}
];
Optimizing Largest Contentful Paint (LCP)
1. Optimize Server Response Time
// Next.js with caching
export const revalidate = 3600; // Revalidate every hour
export async function generateStaticParams() {
// Pre-render important pages
return [
{ id: '1' },
{ id: '2' },
// ... top 100 pages
];
}
// Use edge runtime for faster response
export const runtime = 'edge';
export default async function Page({ params }: { params: { id: string } }) {
// Fetch with caching
const data = await fetch(`https://api.example.com/data/${params.id}`, {
next: { revalidate: 3600 }
});
return <Content data={data} />;
}
2. Eliminate Render-Blocking Resources
<!-- ❌ Bad: Blocks rendering -->
<link rel="stylesheet" href="styles.css">
<!-- ✅ Good: Critical CSS inline, rest deferred -->
<style>
/* Critical CSS only */
body { margin: 0; }
.hero { /* ... */ }
</style>
<link rel="preload" href="styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="styles.css"></noscript>
3. Optimize Images
// Next.js Image component
import Image from 'next/image';
export function Hero() {
return (
<div className="hero">
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={600}
priority // Load immediately for LCP element
sizes="100vw"
quality={85} // Balance quality and size
/>
</div>
);
}
// Modern formats with fallback
<picture>
<source srcset="image.avif" type="image/avif" />
<source srcset="image.webp" type="image/webp" />
<img src="image.jpg" alt="Description" />
</picture>
4. Implement Resource Hints
<!-- Preconnect to required origins -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://api.example.com">
<!-- Preload critical resources -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/hero.jpg" as="image">
<!-- DNS prefetch for third-party domains -->
<link rel="dns-prefetch" href="https://analytics.google.com">
Optimizing First Input Delay (FID) / Interaction to Next Paint (INP)
1. Minimize JavaScript Execution
// ❌ Bad: Heavy synchronous operation
function processData(items: Item[]) {
const results = items.map(item => {
// Heavy computation
return expensiveOperation(item);
});
updateUI(results);
}
// ✅ Good: Break into chunks with requestIdleCallback
function processData(items: Item[]) {
const results: ProcessedItem[] = [];
let index = 0;
function processChunk(deadline: IdleDeadline) {
while (
deadline.timeRemaining() > 0 &&
index < items.length
) {
results.push(expensiveOperation(items[index]));
index++;
}
if (index < items.length) {
requestIdleCallback(processChunk);
} else {
updateUI(results);
}
}
requestIdleCallback(processChunk);
}
2. Code Splitting
// Dynamic imports for large components
import dynamic from 'next/dynamic';
// ❌ Bad: Load everything upfront
import HeavyChart from '@/components/HeavyChart';
import LargeModal from '@/components/LargeModal';
// ✅ Good: Load on demand
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
loading: () => <ChartSkeleton />,
ssr: false // Client-side only if needed
});
const LargeModal = dynamic(() => import('@/components/LargeModal'));
export function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>Show Chart</button>
{showChart && <HeavyChart />}
</div>
);
}
3. Optimize Event Handlers
// ❌ Bad: Heavy work in event handler
function SearchInput() {
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
// Heavy filtering on every keystroke
const results = expensiveSearch(e.target.value);
setResults(results);
};
return <input onChange={handleChange} />;
}
// ✅ Good: Debounce expensive operations
import { useDebouncedCallback } from 'use-debounce';
function SearchInput() {
const debouncedSearch = useDebouncedCallback(
(value: string) => {
const results = expensiveSearch(value);
setResults(results);
},
300 // Wait 300ms after user stops typing
);
return <input onChange={(e) => debouncedSearch(e.target.value)} />;
}
4. Web Workers for Heavy Computation
// worker.ts
self.addEventListener('message', (e) => {
const { data } = e;
const result = heavyComputation(data);
self.postMessage(result);
});
// main.ts
const worker = new Worker(new URL('./worker.ts', import.meta.url));
function processInWorker(data: any): Promise<any> {
return new Promise((resolve) => {
worker.onmessage = (e) => resolve(e.data);
worker.postMessage(data);
});
}
// Usage
const result = await processInWorker(largeDataset);
Optimizing Cumulative Layout Shift (CLS)
1. Reserve Space for Images and Embeds
// ❌ Bad: No dimensions specified
<img src="photo.jpg" alt="Photo" />
// ✅ Good: Explicit dimensions prevent layout shift
<img
src="photo.jpg"
alt="Photo"
width="800"
height="600"
/>
// ✅ Better: Next.js Image with aspect ratio
<Image
src="/photo.jpg"
alt="Photo"
width={800}
height={600}
style={{ width: '100%', height: 'auto' }}
/>
// For responsive images
<div style={{ aspectRatio: '16/9' }}>
<img src="photo.jpg" alt="Photo" style={{ width: '100%' }} />
</div>
2. Avoid Inserting Content Above Existing Content
// ❌ Bad: Shifts content down
function Banner() {
const [show, setShow] = useState(false);
useEffect(() => {
setTimeout(() => setShow(true), 2000);
}, []);
return (
<>
{show && <div className="banner">Important message!</div>}
<main>{/* Content gets shifted down */}</main>
</>
);
}
// ✅ Good: Reserve space or use overlay
function Banner() {
const [show, setShow] = useState(false);
return (
<>
{/* Fixed/absolute positioning doesn't shift content */}
{show && (
<div className="fixed top-0 left-0 right-0 z-50 banner">
Important message!
</div>
)}
<main className={show ? 'mt-16' : ''}>{/* Content */}</main>
</>
);
}
3. Handle Web Fonts Properly
/* ❌ Bad: Causes layout shift */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2');
}
body {
font-family: 'CustomFont', sans-serif;
}
/* ✅ Good: Use font-display to prevent shift */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2');
font-display: swap; /* Show fallback immediately */
}
/* ✅ Better: Match fallback font metrics */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2');
font-display: swap;
size-adjust: 95%; /* Adjust to match fallback */
ascent-override: 85%;
descent-override: 20%;
line-gap-override: 0%;
}
4. Reserve Space for Ads
function AdSlot() {
return (
<div
className="ad-container"
style={{
minHeight: '250px', // Reserve space
backgroundColor: '#f0f0f0'
}}
>
{/* Ad loads here */}
</div>
);
}
Performance Monitoring
1. Real User Monitoring (RUM)
// Send Core Web Vitals to analytics
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';
function sendToAnalytics(metric: Metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
id: metric.id,
});
// Use navigator.sendBeacon() for reliability
if (navigator.sendBeacon) {
navigator.sendBeacon('/analytics', body);
} else {
fetch('/analytics', { body, method: 'POST', keepalive: true });
}
}
getCLS(sendToAnalytics);
getFID(sendToAnalytics);
getLCP(sendToAnalytics);
getFCP(sendToAnalytics);
getTTFB(sendToAnalytics);
2. Performance Budgets
// .lighthouserc.json
{
"ci": {
"collect": {
"numberOfRuns": 3
},
"assert": {
"assertions": {
"categories:performance": ["error", {"minScore": 0.9}],
"largest-contentful-paint": ["error", {"maxNumericValue": 2500}],
"first-input-delay": ["error", {"maxNumericValue": 100}],
"cumulative-layout-shift": ["error", {"maxNumericValue": 0.1}],
"total-byte-weight": ["error", {"maxNumericValue": 1000000}]
}
}
}
}
3. Chrome DevTools Performance Panel
// Profile specific interactions
function ProfiledComponent() {
const handleClick = () => {
performance.mark('interaction-start');
// Your interaction logic
doSomething();
performance.mark('interaction-end');
performance.measure(
'interaction-duration',
'interaction-start',
'interaction-end'
);
const measurements = performance.getEntriesByName('interaction-duration');
console.log('Duration:', measurements[0].duration);
};
return <button onClick={handleClick}>Click me</button>;
}
Advanced Optimization Techniques
1. Priority Hints
<!-- Tell browser what's important -->
<img src="hero.jpg" fetchpriority="high" alt="Hero">
<img src="footer-logo.jpg" fetchpriority="low" alt="Logo">
<link rel="preload" href="critical.css" as="style" fetchpriority="high">
<script src="analytics.js" fetchpriority="low" defer></script>
2. Content Visibility
/* Skip rendering off-screen content */
.content-section {
content-visibility: auto;
contain-intrinsic-size: 0 500px; /* Estimated height */
}
3. Service Worker Caching
// sw.ts
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst } from 'workbox-strategies';
// Precache build assets
precacheAndRoute(self.__WB_MANIFEST);
// Cache images
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images',
plugins: [
{
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
},
],
})
);
// Network first for API
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api',
networkTimeoutSeconds: 3,
})
);
Conclusion
Optimizing Core Web Vitals requires a holistic approach combining server optimization, efficient code, and smart loading strategies. Start by measuring your current metrics, identify the biggest opportunities, and implement optimizations incrementally.