Optimizing Core Web Vitals: Before and After Case Study
Six months ago, our e-commerce site was hemorrhaging users due to poor performance. Our Core Web Vitals were in the red: LCP at 4.2 seconds, CLS at 0.31, and INP at 340ms. Bounce rate was 67%, and conversion rate had dropped to 1.8%.
Today, our metrics tell a different story: LCP at 1.5 seconds (65% improvement), CLS at 0.06 (80% improvement), and INP at 145ms (57% improvement). More importantly, our conversion rate increased to 2.5% and bounce rate dropped to 43%. Here's exactly how we achieved these results.
The Starting Point: Measuring the Pain
Before optimization, I set up comprehensive monitoring to establish baselines. Here's the monitoring infrastructure I built:
// monitoring/web-vitals-tracker.ts
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';
interface VitalsMetric {
name: string;
value: number;
id: string;
delta: number;
rating: 'good' | 'needs-improvement' | 'poor';
}
class WebVitalsTracker {
private metrics: VitalsMetric[] = [];
constructor() {
this.initializeTracking();
}
private initializeTracking() {
getCLS(this.handleMetric.bind(this));
getFID(this.handleMetric.bind(this));
getFCP(this.handleMetric.bind(this));
getLCP(this.handleMetric.bind(this));
getTTFB(this.handleMetric.bind(this));
}
private handleMetric(metric: VitalsMetric) {
this.metrics.push(metric);
this.sendToAnalytics(metric);
this.logToConsole(metric);
}
private sendToAnalytics(metric: VitalsMetric) {
// Send to Google Analytics 4
gtag('event', metric.name, {
event_category: 'Web Vitals',
value: Math.round(metric.value),
custom_parameter_1: metric.rating,
custom_parameter_2: metric.id
});
// Send to custom analytics
fetch('/api/analytics/vitals', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
metric: metric.name,
value: metric.value,
rating: metric.rating,
page: window.location.pathname,
timestamp: Date.now(),
userAgent: navigator.userAgent,
connection: (navigator as any).connection?.effectiveType || 'unknown'
})
}).catch(() => {}); // Fail silently
}
private logToConsole(metric: VitalsMetric) {
const color = {
'good': '#0d9488',
'needs-improvement': '#f59e0b',
'poor': '#dc2626'
}[metric.rating];
console.log(
`%c${metric.name}: ${metric.value.toFixed(2)} (${metric.rating})`,
`color: ${color}; font-weight: bold;`
);
}
getMetrics() {
return this.metrics;
}
getMetricSummary() {
const summary = {
LCP: this.metrics.find(m => m.name === 'LCP'),
FID: this.metrics.find(m => m.name === 'FID'),
CLS: this.metrics.find(m => m.name === 'CLS'),
FCP: this.metrics.find(m => m.name === 'FCP'),
TTFB: this.metrics.find(m => m.name === 'TTFB')
};
return summary;
}
}
// Initialize tracking
const vitalsTracker = new WebVitalsTracker();
// Export for debugging
if (typeof window !== 'undefined') {
(window as any).webVitals = vitalsTracker;
}
Baseline Metrics: The Ugly Truth
Here were our initial measurements across key pages:
// Initial Core Web Vitals (averaged over 28 days)
const baselineMetrics = {
homepage: {
LCP: 4.2, // seconds (target: ≤ 2.5s)
CLS: 0.31, // score (target: ≤ 0.1)
INP: 340, // milliseconds (target: ≤ 200ms)
FCP: 2.8, // seconds
TTFB: 1.2 // seconds
},
productPage: {
LCP: 3.9,
CLS: 0.28,
INP: 295,
FCP: 2.1,
TTFB: 0.9
},
checkoutPage: {
LCP: 5.1, // Worst performer
CLS: 0.45, // Terrible layout shifts
INP: 425, // Very poor interactivity
FCP: 3.2,
TTFB: 1.8
}
};
// Business impact metrics
const businessBaseline = {
bounceRate: 67, // %
conversionRate: 1.8, // %
avgSessionDuration: 45, // seconds
pagesPerSession: 1.2,
mobileTraffic: 73, // % of total traffic
mobileConversionRate: 1.1 // % (worse than desktop)
};
Phase 1: Largest Contentful Paint (LCP) Optimization
LCP was our biggest problem. I identified the main culprits using Chrome DevTools and PageSpeed Insights:
Issue 1: Unoptimized Hero Images
// Before: Manual image handling (problematic)
const HeroSection = () => {
return (
<section className="hero">
<img
src="/images/hero-banner.jpg" // 2.3MB JPEG
alt="Hero banner"
style={{ width: '100%', height: 'auto' }}
/>
<div className="hero-content">
<h1>Welcome to Our Store</h1>
</div>
</section>
);
};
The hero image was 2.3MB and loading synchronously. Here's how I fixed it:
// After: Next.js Image with optimization
import Image from 'next/image';
const HeroSection = () => {
return (
<section className="hero">
<Image
src="/images/hero-banner.jpg"
alt="Hero banner"
width={1920}
height={800}
priority // Load immediately for LCP
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1920px"
style={{
width: '100%',
height: 'auto',
}}
/>
<div className="hero-content">
<h1>Welcome to Our Store</h1>
</div>
</section>
);
};
Issue 2: Render-Blocking CSS
I identified 340KB of render-blocking CSS. Here's my solution:
// utils/critical-css.ts
export function CriticalCSS() {
return (
<style jsx>{`
/* Only above-the-fold styles */
.hero {
position: relative;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.hero-content {
position: absolute;
z-index: 2;
text-align: center;
color: white;
}
.hero h1 {
font-size: 3rem;
font-weight: 700;
margin-bottom: 1rem;
text-shadow: 2px 2px 4px rgba(0,0,0,0.7);
}
/* Critical navigation styles */
.nav {
position: fixed;
top: 0;
width: 100%;
z-index: 10;
background: rgba(255,255,255,0.95);
backdrop-filter: blur(10px);
}
`}</style>
);
}
// pages/_app.tsx
import { CriticalCSS } from '../utils/critical-css';
export default function App({ Component, pageProps }) {
return (
<>
<CriticalCSS />
<Component {...pageProps} />
</>
);
}
Issue 3: Slow Server Response
TTFB was 1.2 seconds. I optimized our API responses:
// lib/cache-optimization.ts
import { NextRequest, NextResponse } from 'next/server';
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
export function withCache(handler: Function, ttl: number = 300) {
return async (req: NextRequest) => {
const cacheKey = `api:${req.url}:${JSON.stringify(req.body || {})}`;
// Try cache first
const cached = await redis.get(cacheKey);
if (cached) {
console.log(`Cache hit for ${req.url}`);
return NextResponse.json(JSON.parse(cached), {
headers: {
'X-Cache': 'HIT',
'Cache-Control': `public, max-age=${ttl}, s-maxage=${ttl}`
}
});
}
// Execute handler
const result = await handler(req);
const data = await result.json();
// Cache the result
await redis.setex(cacheKey, ttl, JSON.stringify(data));
return NextResponse.json(data, {
headers: {
'X-Cache': 'MISS',
'Cache-Control': `public, max-age=${ttl}, s-maxage=${ttl}`
}
});
};
}
// Usage in API routes
// pages/api/products.ts
export default withCache(async (req: NextRequest) => {
const products = await getProductsFromDb();
return NextResponse.json(products);
}, 600); // Cache for 10 minutes
LCP Results After Phase 1:
- Homepage: 4.2s → 2.3s (45% improvement)
- Product Page: 3.9s → 2.1s (46% improvement)
- Checkout: 5.1s → 2.8s (45% improvement)
Phase 2: Cumulative Layout Shift (CLS) Optimization
CLS was causing major usability issues. Users were clicking wrong buttons due to layout shifts.
Issue 1: Images Without Dimensions
// Before: Layout shifts from images loading
const ProductGrid = ({ products }) => {
return (
<div className="grid">
{products.map(product => (
<div key={product.id} className="product-card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
))}
</div>
);
};
// After: Proper aspect ratio containers
import Image from 'next/image';
const ProductGrid = ({ products }) => {
return (
<div className="grid">
{products.map(product => (
<div key={product.id} className="product-card">
<div className="image-container">
<Image
src={product.image}
alt={product.name}
width={300}
height={300}
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 300px"
style={{
objectFit: 'cover'
}}
/>
</div>
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
))}
</div>
);
};
/* CSS for stable layouts */
.product-card {
display: flex;
flex-direction: column;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
transition: transform 0.2s ease;
}
.image-container {
position: relative;
width: 100%;
aspect-ratio: 1 / 1; /* Prevents layout shift */
background: #f3f4f6; /* Placeholder color while loading */
}
.product-card h3 {
height: 3rem; /* Fixed height prevents text wrapping shifts */
display: flex;
align-items: center;
padding: 1rem;
margin: 0;
font-size: 1.125rem;
line-height: 1.5;
overflow: hidden;
}
Issue 2: Dynamic Content Injection
Our notification system was injecting content at the top of pages:
// Before: Notifications causing layout shifts
const NotificationBanner = ({ notifications }) => {
if (!notifications.length) return null;
return (
<div className="notification-banner">
{notifications.map(notification => (
<div key={notification.id} className="notification">
{notification.message}
</div>
))}
</div>
);
};
// This was being injected at runtime, pushing content down
// After: Reserved space for notifications
const Layout = ({ children, notifications = [] }) => {
return (
<div className="layout">
{/* Always reserve space, even when empty */}
<div className="notification-container"
style={{
minHeight: notifications.length > 0 ? 'auto' : '0',
maxHeight: notifications.length > 0 ? '200px' : '0',
overflow: 'hidden',
transition: 'max-height 0.3s ease-in-out'
}}>
{notifications.map(notification => (
<div key={notification.id} className="notification">
{notification.message}
</div>
))}
</div>
<main>{children}</main>
</div>
);
};
Issue 3: Web Font Loading
Fonts were causing text to shift during loading:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
// Optimize fonts
optimizeFonts: true,
// Custom webpack config for font loading
webpack: (config) => {
config.module.rules.push({
test: /\.(woff|woff2|eot|ttf|otf)$/,
use: {
loader: 'file-loader',
options: {
publicPath: '/_next/static/fonts/',
outputPath: 'static/fonts/',
esModule: false
}
}
});
return config;
}
};
module.exports = nextConfig;
// pages/_document.tsx
import Document, { Html, Head, Main, NextScript } from 'next/document';
export default class MyDocument extends Document {
render() {
return (
<Html lang="en">
<Head>
{/* Preload critical fonts */}
<link
rel="preload"
href="/fonts/inter-var.woff2"
as="font"
type="font/woff2"
crossOrigin=""
/>
{/* Font display strategy */}
<style jsx>{`
@font-face {
font-family: 'Inter var';
src: url('/fonts/inter-var.woff2') format('woff2-variations');
font-weight: 100 900;
font-display: swap; /* Prevents invisible text during font load */
font-style: normal;
}
body {
font-family: 'Inter var', -apple-system, BlinkMacSystemFont, sans-serif;
}
`}</style>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
}
CLS Results After Phase 2:
- Homepage: 0.31 → 0.08 (74% improvement)
- Product Page: 0.28 → 0.06 (79% improvement)
- Checkout: 0.45 → 0.09 (80% improvement)
Phase 3: Interaction to Next Paint (INP) Optimization
INP replaced FID as a Core Web Vitals metric in 2024. Our interactive elements were sluggish.
Issue 1: Heavy JavaScript Execution
// Before: Blocking main thread with heavy calculations
const ProductFilter = ({ products, onFilterChange }) => {
const [filters, setFilters] = useState({});
// This was blocking the main thread
const filteredProducts = useMemo(() => {
return products.filter(product => {
// Complex filtering logic that took 100ms+
let matches = true;
if (filters.category) {
matches = matches && product.category === filters.category;
}
if (filters.priceRange) {
const [min, max] = filters.priceRange;
matches = matches && product.price >= min && product.price <= max;
}
if (filters.rating) {
matches = matches && product.rating >= filters.rating;
}
// Heavy text search
if (filters.search) {
const searchLower = filters.search.toLowerCase();
matches = matches && (
product.name.toLowerCase().includes(searchLower) ||
product.description.toLowerCase().includes(searchLower) ||
product.tags.some(tag => tag.toLowerCase().includes(searchLower))
);
}
return matches;
});
}, [products, filters]);
return (
<div>
<FilterControls onFilterChange={setFilters} />
<ProductList products={filteredProducts} />
</div>
);
};
// After: Web Worker for heavy computations
// workers/product-filter.worker.ts
export default function filterProducts(products, filters) {
return products.filter(product => {
let matches = true;
if (filters.category) {
matches = matches && product.category === filters.category;
}
if (filters.priceRange) {
const [min, max] = filters.priceRange;
matches = matches && product.price >= min && product.price <= max;
}
if (filters.rating) {
matches = matches && product.rating >= filters.rating;
}
if (filters.search) {
const searchLower = filters.search.toLowerCase();
matches = matches && (
product.name.toLowerCase().includes(searchLower) ||
product.description.toLowerCase().includes(searchLower) ||
product.tags.some(tag => tag.toLowerCase().includes(searchLower))
);
}
return matches;
});
}
// hooks/useProductFilter.ts
import { useMemo } from 'react';
import { useWebWorker } from './useWebWorker';
export function useProductFilter(products, filters) {
const { worker, isLoading } = useWebWorker('/workers/product-filter.worker.js');
const filteredProducts = useMemo(() => {
if (!worker || isLoading) return products;
// Non-blocking filter operation
return worker.postMessage({ products, filters });
}, [products, filters, worker, isLoading]);
return { filteredProducts, isLoading };
}
Issue 2: Inefficient Event Handlers
// Before: Heavy event handlers
const SearchInput = ({ onSearch, products }) => {
const [query, setQuery] = useState('');
const handleInputChange = (e) => {
const value = e.target.value;
setQuery(value);
// This ran on every keystroke!
const results = products.filter(product =>
product.name.toLowerCase().includes(value.toLowerCase()) ||
product.description.toLowerCase().includes(value.toLowerCase())
);
onSearch(results);
};
return (
<input
type="text"
value={query}
onChange={handleInputChange}
placeholder="Search products..."
/>
);
};
// After: Debounced and optimized search
import { useMemo, useCallback } from 'react';
import { debounce } from 'lodash';
const SearchInput = ({ onSearch, products }) => {
const [query, setQuery] = useState('');
// Create debounced search function
const debouncedSearch = useMemo(
() => debounce((searchQuery) => {
if (!searchQuery.trim()) {
onSearch(products);
return;
}
// Use requestIdleCallback for non-blocking search
requestIdleCallback(() => {
const results = products.filter(product =>
product.searchIndex?.includes(searchQuery.toLowerCase())
);
onSearch(results);
});
}, 300),
[products, onSearch]
);
const handleInputChange = useCallback((e) => {
const value = e.target.value;
setQuery(value);
debouncedSearch(value);
}, [debouncedSearch]);
return (
<input
type="text"
value={query}
onChange={handleInputChange}
placeholder="Search products..."
/>
);
};
Issue 3: Third-Party Script Impact
Analytics and chat widgets were blocking interactions:
// lib/third-party-loader.ts
export class ThirdPartyLoader {
private static loadedScripts = new Set<string>();
private static observers = new Map<string, IntersectionObserver>();
static loadOnIdle(src: string, name: string) {
if (this.loadedScripts.has(name)) return;
requestIdleCallback(() => {
this.loadScript(src, name);
});
}
static loadOnInteraction(src: string, name: string, events: string[] = ['click', 'scroll']) {
if (this.loadedScripts.has(name)) return;
const loadScript = () => {
this.loadScript(src, name);
// Remove event listeners after loading
events.forEach(event => {
document.removeEventListener(event, loadScript);
});
};
events.forEach(event => {
document.addEventListener(event, loadScript, { once: true, passive: true });
});
}
static loadOnVisible(src: string, name: string, selector: string) {
if (this.loadedScripts.has(name)) return;
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.loadScript(src, name);
observer.disconnect();
}
});
});
const element = document.querySelector(selector);
if (element) {
observer.observe(element);
this.observers.set(name, observer);
}
}
private static loadScript(src: string, name: string) {
if (this.loadedScripts.has(name)) return;
const script = document.createElement('script');
script.src = src;
script.async = true;
script.onload = () => {
console.log(`Third-party script loaded: ${name}`);
};
document.head.appendChild(script);
this.loadedScripts.add(name);
}
}
// Usage in components
useEffect(() => {
// Load analytics on first user interaction
ThirdPartyLoader.loadOnInteraction(
'https://www.googletagmanager.com/gtag/js',
'google-analytics'
);
// Load chat widget when user scrolls to bottom
ThirdPartyLoader.loadOnVisible(
'https://widget.intercom.io/widget.js',
'intercom-chat',
'footer'
);
// Load other non-critical scripts when browser is idle
ThirdPartyLoader.loadOnIdle(
'https://connect.facebook.net/en_US/sdk.js',
'facebook-sdk'
);
}, []);
INP Results After Phase 3:
- Homepage: 340ms → 145ms (57% improvement)
- Product Page: 295ms → 128ms (57% improvement)
- Checkout: 425ms → 165ms (61% improvement)
The Monitoring Dashboard
I built a real-time dashboard to track our progress:
// components/VitalsDashboard.tsx
import { useEffect, useState } from 'react';
import { getCLS, getLCP, getFID } from 'web-vitals';
export function VitalsDashboard() {
const [vitals, setVitals] = useState({
LCP: { value: 0, rating: 'good' },
CLS: { value: 0, rating: 'good' },
INP: { value: 0, rating: 'good' }
});
useEffect(() => {
getLCP((metric) => {
setVitals(prev => ({
...prev,
LCP: { value: metric.value, rating: metric.rating }
}));
});
getCLS((metric) => {
setVitals(prev => ({
...prev,
CLS: { value: metric.value, rating: metric.rating }
}));
});
getFID((metric) => {
setVitals(prev => ({
...prev,
INP: { value: metric.value, rating: metric.rating }
}));
});
}, []);
const getRatingColor = (rating: string) => {
switch (rating) {
case 'good': return '#0d9488';
case 'needs-improvement': return '#f59e0b';
case 'poor': return '#dc2626';
default: return '#6b7280';
}
};
return (
<div className="vitals-dashboard">
<h3>Core Web Vitals</h3>
<div className="metrics-grid">
<div className="metric-card">
<h4>LCP</h4>
<div
className="metric-value"
style={{ color: getRatingColor(vitals.LCP.rating) }}
>
{(vitals.LCP.value / 1000).toFixed(2)}s
</div>
<div className="metric-rating">{vitals.LCP.rating}</div>
</div>
<div className="metric-card">
<h4>CLS</h4>
<div
className="metric-value"
style={{ color: getRatingColor(vitals.CLS.rating) }}
>
{vitals.CLS.value.toFixed(3)}
</div>
<div className="metric-rating">{vitals.CLS.rating}</div>
</div>
<div className="metric-card">
<h4>INP</h4>
<div
className="metric-value"
style={{ color: getRatingColor(vitals.INP.rating) }}
>
{vitals.INP.value.toFixed(0)}ms
</div>
<div className="metric-rating">{vitals.INP.rating}</div>
</div>
</div>
</div>
);
}
Final Results: The Numbers Don't Lie
After 6 months of systematic optimization:
// Final Core Web Vitals comparison
const finalComparison = {
homepage: {
before: { LCP: 4.2, CLS: 0.31, INP: 340 },
after: { LCP: 1.5, CLS: 0.06, INP: 145 },
improvement: { LCP: '65%', CLS: '81%', INP: '57%' }
},
productPage: {
before: { LCP: 3.9, CLS: 0.28, INP: 295 },
after: { LCP: 1.3, CLS: 0.05, INP: 128 },
improvement: { LCP: '67%', CLS: '82%', INP: '57%' }
},
checkoutPage: {
before: { LCP: 5.1, CLS: 0.45, INP: 425 },
after: { LCP: 1.8, CLS: 0.09, INP: 165 },
improvement: { LCP: '65%', CLS: '80%', INP: '61%' }
}
};
// Business impact
const businessResults = {
bounceRate: {
before: 67,
after: 43,
improvement: '36% decrease'
},
conversionRate: {
before: 1.8,
after: 2.5,
improvement: '39% increase'
},
avgSessionDuration: {
before: 45, // seconds
after: 72,
improvement: '60% increase'
},
pagesPerSession: {
before: 1.2,
after: 2.1,
improvement: '75% increase'
},
mobileConversionRate: {
before: 1.1,
after: 2.2,
improvement: '100% increase'
}
};
Lessons Learned
- Start with measurement - You can't improve what you don't measure
- Prioritize by impact - LCP improvements had the biggest business impact
- Test on real devices - Lab metrics don't always reflect real user experience
- Monitor continuously - Performance regression is easy without constant monitoring
- Focus on mobile first - 73% of our traffic was mobile, yet we optimized desktop first initially
The investment in Core Web Vitals optimization paid immediate dividends. Beyond the obvious performance improvements, the systematic approach gave our team confidence in making data-driven decisions about performance.
Performance isn't just about vanity metrics—it directly impacts user experience and business outcomes. Every second you shave off load time, every layout shift you prevent, and every interaction you make smoother translates to happier users and better business results.