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