Optimizing Core Web Vitals: Before and After Case Study

14 min read2684 words

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

  1. Start with measurement - You can't improve what you don't measure
  2. Prioritize by impact - LCP improvements had the biggest business impact
  3. Test on real devices - Lab metrics don't always reflect real user experience
  4. Monitor continuously - Performance regression is easy without constant monitoring
  5. 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.