How I Reduced LCP from 5.8s to 2.5s in a Next.js E-commerce
Why LCP Matters (Beyond Google)
- The hero image
- The product image on PDPs
- The first visible product cards on category pages
| LCP | Bounce Rate | Conversion |
|---|---|---|
| 5.8s | 62% | 1.8% |
| 4.0s | 48% | 2.1% |
| 2.5s | 34% | 2.6% |
The Audit: Finding the Culprits
- Lighthouse (Chrome DevTools)
- WebPageTest (for real-device testing)
- Vercel Analytics (real user data)
- Unoptimized images - Hero images were 2MB+ PNGs
- Render-blocking JavaScript - 800KB of JS before first paint
- No image prioritization - LCP image loaded after other resources
- Third-party scripts - Analytics, chat widgets loading synchronously
- No caching strategy - Every visit fetched everything fresh
Fix #1: Image Optimization (Biggest Win)
Before
// ❌ Raw img tag with massive PNG
<img src="/hero-banner.png" alt="Sale" />After
// ✅ Next.js Image with priority
import Image from 'next/image'
<Image
src="/hero-banner.png"
alt="Sale"
width={1200}
height={600}
priority // Tells Next.js this is the LCP image
placeholder="blur"
blurDataURL={blurDataUrl}
/>- Automatic WebP/AVIF conversion (-70% file size)
- Responsive srcset (right size for each device)
- Lazy loading for below-fold images
- Blur placeholder for perceived performance
Product Images at Scale
// next.config.js
module.exports = {
images: {
formats: ['image/avif', 'image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200],
imageSizes: [16, 32, 48, 64, 96, 128, 256],
},
}function ProductCard({ product }) {
return (
<div className="product-card">
<Image
src={product.image}
alt={product.name}
width={300}
height={300}
loading="lazy" // Not priority - these are below fold
placeholder="blur"
blurDataURL={product.blurHash}
/>
</div>
)
}Fix #2: Code Splitting & Bundle Optimization
Dynamic Imports for Heavy Components
// ❌ Before: Everything loads upfront
import { HeavyChart } from '@/components/HeavyChart'
import { ReviewsCarousel } from '@/components/ReviewsCarousel'
// ✅ After: Load when needed
import dynamic from 'next/dynamic'
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
loading: () => <ChartSkeleton />,
ssr: false // Client-only component
})
const ReviewsCarousel = dynamic(() => import('@/components/ReviewsCarousel'), {
loading: () => <ReviewsSkeleton />
})Route-Based Splitting
// pages/product/[slug].tsx
// Only loads product page code when visiting /product/*
// pages/checkout/index.tsx
// Checkout code separate from product browsingAnalyzing the Bundle
# Add to package.json scripts
"analyze": "ANALYZE=true next build"// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer({
// config
})// ❌ Imports entire lodash (70KB)
import _ from 'lodash'
// ✅ Imports only what we need (4KB)
import debounce from 'lodash/debounce'Fix #3: Critical Rendering Path
Preconnect to Essential Origins
// app/layout.tsx or pages/_document.tsx
<Head>
<link rel="preconnect" href="https://cdn.example.com" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="dns-prefetch" href="https://analytics.example.com" />
</Head>Font Optimization
// ❌ Before: Fonts block rendering
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap" rel="stylesheet">
// ✅ After: Next.js font optimization
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap', // Show fallback while loading
preload: true,
})Inline Critical CSS
// Critical CSS for hero section
<style dangerouslySetInnerHTML={{ __html: `
.hero { min-height: 60vh; }
.hero-image { object-fit: cover; }
`}} />Fix #4: Third-Party Script Management
Defer Non-Essential Scripts
// ❌ Before: Blocks rendering
<script src="https://chat-widget.com/loader.js"></script>
// ✅ After: Load after page is interactive
<Script
src="https://chat-widget.com/loader.js"
strategy="lazyOnload" // Loads after everything else
/>Load Analytics Properly
import Script from 'next/script'
// Google Analytics - load after hydration
<Script
src="https://www.googletagmanager.com/gtag/js?id=GA_ID"
strategy="afterInteractive"
/>Facade Pattern for Heavy Widgets
function ChatWidget() {
const [loaded, setLoaded] = useState(false)
if (!loaded) {
return (
<button onClick={() => setLoaded(true)}>
💬 Chat with us
</button>
)
}
return <ActualChatWidget />
}Fix #5: Caching Strategy
Static Generation Where Possible
// Product pages: ISR with 60-second revalidation
export async function generateStaticParams() {
const products = await getTopProducts(100)
return products.map(p => ({ slug: p.slug }))
}
export const revalidate = 60 // Regenerate every 60 secondsCache Headers for Assets
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/images/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
},
],
},
]
},
}The Results
| Metric | Before | After | Change |
|---|---|---|---|
| LCP | 5.8s | 2.5s | -56% |
| FCP | 3.2s | 1.4s | -56% |
| TTI | 8.1s | 4.2s | -48% |
| Bundle Size | 800KB | 320KB | -60% |
| Lighthouse | 54 | 92 | +70% |
| Metric | Before | After | Change |
|---|---|---|---|
| Bounce Rate | 62% | 34% | -45% |
| Conversion | 1.8% | 2.6% | +44% |
| Avg. Session | 1:45 | 3:12 | +83% |
Key Takeaways
- Measure first - Don't guess. Use real user data from Vercel Analytics or similar.
- Images are usually the biggest win - Next.js Image component is your friend.
- JavaScript is expensive - Every KB of JS has a cost. Code-split aggressively.
- Third-party scripts add up - Audit everything loading on your page.
- Performance = Revenue - For e-commerce, speed directly impacts the bottom line.
Tools I Use
- Lighthouse - Quick audits during development
- WebPageTest - Real device testing with filmstrip view
- Vercel Analytics - Real user metrics in production
- Bundle Analyzer - Understanding what's in your JS bundle
Need help optimizing your Next.js application? Let's talk →


