Back to Stories
FrontendDevTime Team

Achieving Perfect Lighthouse Scores: A Core Web Vitals Journey

How we optimized a React application from 60 to 100 Lighthouse score through systematic performance improvements.

#performance#react#lighthouse#web-vitals

Achieving Perfect Lighthouse Scores

When we first ran Lighthouse on our client's e-commerce platform, the results were... humbling. A performance score of 62, with red metrics across the board. Three months later, we achieved a consistent 100. Here's how we did it.

The Starting Point

Our initial Lighthouse audit revealed several critical issues:

  • LCP (Largest Contentful Paint): 4.2s ❌ (target: <2.5s)
  • FID (First Input Delay): 180ms ❌ (target: <100ms)
  • CLS (Cumulative Layout Shift): 0.25 ❌ (target: <0.1)
  • Performance Score: 62/100 ❌

Info

Core Web Vitals are Google's metrics for measuring user experience. They directly impact SEO rankings and, more importantly, user satisfaction and conversion rates.

Strategy 1: Image Optimization

Images accounted for 78% of our page weight. We implemented:

Next.js Image Component

Switching to Next.js's <Image> component gave us automatic optimization:

hljs tsx
// Before: Standard img tag
<img src="/hero.jpg" alt="Hero" />

// After: Next.js Image with lazy loading
import Image from 'next/image';

<Image
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={600}
  priority // for above-the-fold images
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,..."
/>

Impact: LCP improved from 4.2s → 3.1s

WebP with AVIF Fallback

We converted all images to modern formats:

hljs tsx
<picture>
  <source srcSet="/hero.avif" type="image/avif" />
  <source srcSet="/hero.webp" type="image/webp" />
  <img src="/hero.jpg" alt="Fallback" />
</picture>

Impact: Page weight reduced by 64%

Strategy 2: Code Splitting

Our bundle size was massive: 847KB gzipped. We implemented aggressive code splitting:

hljs typescript
// Dynamic imports for heavy components
import dynamic from 'next/dynamic';

const HeavyChart = dynamic(() => import('@/components/Chart'), {
  loading: () => <Skeleton />,
  ssr: false // Don't render on server
});

const ProductGallery = dynamic(
  () => import('@/components/ProductGallery'),
  { ssr: true }
);

We also analyzed our bundle with @next/bundle-analyzer:

hljs bash
npm run build -- --analyze

Tip

We discovered that moment.js (which we rarely used) was adding 288KB! We replaced it with date-fns and only imported the functions we needed, saving 270KB.

Impact: Initial bundle size reduced from 847KB → 312KB

Strategy 3: Font Optimization

Custom fonts were causing significant layout shift. We fixed this with font-display strategies:

hljs css
@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom.woff2') format('woff2');
  font-display: swap; /* Use fallback font immediately */
  font-weight: 400;
}

/* Define fallback font with similar metrics */
.text {
  font-family: 'CustomFont', -apple-system, system-ui, sans-serif;
}

We also preloaded critical fonts:

hljs tsx
// In _document.tsx or layout.tsx
<link
  rel="preload"
  href="/fonts/custom.woff2"
  as="font"
  type="font/woff2"
  crossOrigin="anonymous"
/>

Impact: CLS improved from 0.25 → 0.08

Strategy 4: JavaScript Optimization

Virtualization for Long Lists

Product listings were rendering 500+ items at once. We implemented virtualization:

hljs tsx
import { FixedSizeList } from 'react-window';

function ProductList({ items }) {
  return (
    <FixedSizeList
      height={600}
      itemCount={items.length}
      itemSize={120}
      width="100%"
    >
      {({ index, style }) => (
        <ProductCard
          style={style}
          product={items[index]}
        />
      )}
    </FixedSizeList>
  );
}

Impact: FID improved from 180ms → 45ms

React.memo for Expensive Components

We wrapped expensive components to prevent unnecessary re-renders:

hljs tsx
const ProductCard = React.memo(({ product }) => {
  return (
    <div className="product-card">
      {/* ... */}
    </div>
  );
}, (prevProps, nextProps) => {
  // Custom comparison
  return prevProps.product.id === nextProps.product.id;
});

Warning

Don't over-optimize! React.memo has overhead. Only use it for components that re-render frequently with the same props.

Strategy 5: Critical CSS

We inlined critical CSS to prevent render-blocking:

hljs tsx
// Extract critical CSS for above-the-fold content
import { getCriticalCSS } from '@/lib/critical-css';

export default function RootLayout({ children }) {
  const criticalCSS = getCriticalCSS();

  return (
    <html>
      <head>
        <style dangerouslySetInnerHTML={{ __html: criticalCSS }} />
      </head>
      <body>{children}</body>
    </html>
  );
}

The Results

After systematic optimization:

| Metric | Before | After | Change | |--------|--------|-------|--------| | LCP | 4.2s | 1.8s | ✅ -57% | | FID | 180ms | 45ms | ✅ -75% | | CLS | 0.25 | 0.06 | ✅ -76% | | Performance | 62 | 100 | ✅ +38 points |

Business Impact

The performance improvements translated directly to business metrics:

  • Conversion rate: +23%
  • Bounce rate: -31%
  • Average session duration: +42%
  • Mobile traffic: +18% (better mobile experience)

ROI of Performance

Every 100ms of improvement in load time increased conversions by 1%. Performance optimization is not just technical – it's a business imperative.

Key Lessons

  1. Measure first: Use Lighthouse CI in your deployment pipeline
  2. Optimize images: They're usually the biggest win
  3. Think critical path: What does the user need to see first?
  4. Monitor in production: Synthetic tests don't capture real user experience
  5. Iterate continuously: Performance is not a one-time task

Tools We Used

  • Lighthouse CI: Automated performance testing
  • Chrome DevTools: Performance profiling
  • WebPageTest: Real-world performance testing
  • Next.js Bundle Analyzer: Bundle size analysis
  • react-window: List virtualization

Want to learn more about web performance? Check out web.dev for comprehensive guides and best practices.

Want to read more?

View All Stories