Optimizing Core Web Vitals for e-Commerce
Our journey to scoring 100 on Google PageSpeed Insights for a major Shopify-backed e-commerce platform.
Tags
Optimizing Core Web Vitals for e-Commerce
TL;DR
Achieving a perfect PageSpeed score on an e-commerce site requires deferring third-party scripts, optimizing font loading with next/font, and prioritizing hero image fetching to fix LCP. I took an e-commerce platform from failing Core Web Vitals scores to consistently green metrics across all three pillars: LCP, CLS, and INP.
The Challenge
An e-commerce company running a Next.js storefront backed by Shopify's Storefront API had a performance problem that was directly impacting revenue. Their Google PageSpeed Insights scores were consistently in the 40-55 range on mobile. Field data from Chrome User Experience Report (CrUX) confirmed the lab results: real users were experiencing slow loads.
The business impact was tangible. The site had dropped in Google search rankings after the Page Experience update, which uses Core Web Vitals as a ranking signal. Organic traffic had declined, and the team suspected that slow load times were contributing to a cart abandonment rate higher than industry averages.
The three Core Web Vitals I needed to address were:
- ›Largest Contentful Paint (LCP): Measures how long it takes for the largest visible content element to render. The site's LCP was hovering around 4.5 seconds on mobile. Google's threshold for "good" is under 2.5 seconds.
- ›Cumulative Layout Shift (CLS): Measures visual stability. Elements were shifting around as fonts loaded, images rendered, and third-party scripts injected content. CLS was at 0.18, well above the 0.1 "good" threshold.
- ›Interaction to Next Paint (INP): Measures responsiveness. Heavy JavaScript on the main thread was causing delays between user interactions and visual updates. INP was around 350ms, above the 200ms threshold.
The constraint was that I could not remove functionality. The site relied on multiple third-party scripts: Google Analytics, Facebook Pixel, Hotjar session recording, Klaviyo email capture, and a live chat widget. Each of these was contributing to the problem, but removing them was not an option.
The Architecture
Diagnosing the Bottlenecks
Before optimizing anything, I needed to understand exactly where time was being spent. I used a combination of tools:
- ›Lighthouse CI in the development pipeline for consistent lab measurements
- ›Chrome DevTools Performance tab for flame chart analysis
- ›WebPageTest for filmstrip view and connection-level waterfall analysis
- ›CrUX Dashboard for real-world field data trends
The audit revealed three primary bottlenecks, each impacting a different Core Web Vital.
Fixing LCP: Hero Images and Font Loading
The LCP element on most pages was the hero image: a large product photograph or promotional banner. Two issues were delaying its render.
Problem 1: The hero image was not prioritized. The browser's default loading behavior treats all images equally. In a page with 30+ product thumbnails, the hero image was competing with below-the-fold images for bandwidth.
Solution: Use Next.js Image component with the priority attribute, which adds fetchpriority="high" and preloads the image:
import Image from 'next/image';
export function HeroBanner({ image, alt }: HeroBannerProps) {
return (
<Image
src={image}
alt={alt}
width={1200}
height={600}
priority // Preloads the image and sets fetchpriority="high"
sizes="100vw"
className="h-auto w-full object-cover"
/>
);
}I also added explicit sizes attributes to all images. Without sizes, the browser assumes the image fills the full viewport width and downloads the largest available srcset variant. Providing accurate sizes reduced image download sizes significantly for product grid thumbnails:
<Image
src={product.image}
alt={product.name}
width={400}
height={400}
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
/>Problem 2: Fonts were blocking render. The site used a custom brand font loaded via Google Fonts with a standard <link> tag. The browser delayed rendering text until the font file downloaded, adding 300-800ms to LCP depending on connection speed.
Solution: Switch to next/font, which downloads fonts at build time, self-hosts them, and applies font-display: swap automatically:
// app/layout.tsx
import { Inter, Playfair_Display } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
});
const playfair = Playfair_Display({
subsets: ['latin'],
display: 'swap',
variable: '--font-playfair',
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} ${playfair.variable}`}>
<body>{children}</body>
</html>
);
}This eliminated the external network request to Google Fonts entirely. The font files are served from the same domain as the application, avoiding DNS lookups, connection setup, and cross-origin overhead.
Fixing CLS: Layout Stability
Layout shift was caused by three distinct issues:
Problem 1: Images without dimensions. Product images loaded without explicit width and height attributes, causing the browser to allocate zero space initially and then reflow when the image loaded.
Solution: The Next.js Image component requires width and height props (or fill with a sized container), which automatically generates the correct aspect-ratio CSS. I audited every image usage and ensured proper dimensions were provided. For dynamically sized images from the CMS, I used the fill prop with a container that had a fixed aspect ratio:
<div className="relative aspect-square">
<Image src={product.image} alt={product.name} fill className="object-cover" />
</div>Problem 2: Font swap flash. Even with font-display: swap, switching from the fallback font to the custom font causes a layout shift because the fonts have different metrics (character width, line height).
Solution: next/font can generate automatic size-adjust CSS that matches the fallback font's metrics to the custom font's metrics, minimizing the reflow when the swap occurs. The adjustFontFallback option handles this:
const inter = Inter({
subsets: ['latin'],
display: 'swap',
adjustFontFallback: true, // Default for next/font/google
});Problem 3: Third-party script injection. The Klaviyo email popup and live chat widget injected DOM elements after the initial render, pushing page content down.
Solution: I reserved space for these elements using CSS min-height on their container elements and loaded the scripts with the afterInteractive strategy so they did not interfere with the initial paint:
import Script from 'next/script';
// Chat widget with reserved space
<div className="fixed bottom-4 right-4" style={{ minHeight: '60px', minWidth: '60px' }}>
<Script
src="https://widget.livechat.example.com/sdk.js"
strategy="afterInteractive"
/>
</div>Fixing INP: Main Thread Performance
INP measures the delay between a user action (click, tap, keypress) and the browser's visual response. High INP means the main thread is blocked by JavaScript execution.
Problem: Third-party scripts were hogging the main thread. The flame chart showed that Google Analytics, Facebook Pixel, and Hotjar were collectively consuming 200-400ms of main thread time during page load, and their ongoing event listeners continued to compete for CPU during user interactions.
Solution 1: Defer non-critical scripts. I categorized every third-party script by criticality:
// Critical: Analytics needs to capture page view
<Script
src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"
strategy="afterInteractive"
/>
// Non-critical: Session recording can load after everything else
<Script
src="https://static.hotjar.com/c/hotjar-XXXXXX.js"
strategy="lazyOnload"
/>
// Non-critical: Email capture popup doesn't need to load immediately
<Script
src="https://static.klaviyo.com/onsite/js/klaviyo.js"
strategy="lazyOnload"
/>The lazyOnload strategy uses requestIdleCallback to load scripts only when the browser is idle, keeping the main thread free for user interactions during the critical loading period.
Solution 2: Break up long tasks. Some product listing pages had filtering logic that processed hundreds of products synchronously. I refactored these to use startTransition for non-urgent state updates:
'use client';
import { useState, useTransition } from 'react';
function ProductFilter({ products }: { products: Product[] }) {
const [filtered, setFiltered] = useState(products);
const [isPending, startTransition] = useTransition();
function handleFilterChange(filters: FilterCriteria) {
startTransition(() => {
const result = applyFilters(products, filters);
setFiltered(result);
});
}
return (
<div>
<FilterControls onChange={handleFilterChange} />
<div className={isPending ? 'opacity-60' : ''}>
<ProductGrid products={filtered} />
</div>
</div>
);
}startTransition marks the filter update as non-urgent, allowing the browser to process user input (like typing in a search box) without waiting for the product list to re-render.
Performance Monitoring in Production
Lab scores are useful for development but field data is what Google uses for ranking. I set up continuous monitoring using the web-vitals library to report real user metrics:
import { onLCP, onCLS, onINP } from 'web-vitals';
function sendToAnalytics(metric: { name: string; value: number; id: string }) {
fetch('/api/vitals', {
method: 'POST',
body: JSON.stringify(metric),
headers: { 'Content-Type': 'application/json' },
});
}
onLCP(sendToAnalytics);
onCLS(sendToAnalytics);
onINP(sendToAnalytics);This data was aggregated in a dashboard that tracked p75 values (the threshold Google uses) over time, broken down by page type (home, product listing, product detail, checkout).
Key Decisions & Trade-offs
Deferring analytics to afterInteractive instead of lazyOnload. The marketing team needed accurate page view tracking, which requires the analytics script to load reasonably early. Using lazyOnload for analytics would have missed page views from users who bounced quickly. The compromise was afterInteractive for analytics and lazyOnload for everything else.
next/font over self-hosting manually. I could have downloaded the font files and hosted them manually with full control over caching headers. next/font abstracts this away, and the trade-off is less control but zero configuration. For this project, the convenience outweighed the need for custom caching.
Not implementing a service worker for asset caching. A service worker could have provided offline support and faster repeat visits. I decided against it because the e-commerce content changes frequently (prices, inventory, promotions), and stale cache management adds complexity that was not justified for the performance gain.
Using CSS aspect-ratio over padding-bottom hack. The traditional approach to reserving image space was the padding-bottom percentage trick. Modern browser support for the CSS aspect-ratio property is now comprehensive enough that I used it directly, resulting in cleaner markup.
Results & Outcomes
After the optimization work, the site's Core Web Vitals improved dramatically across all three metrics:
- ›LCP dropped from ~4.5s to under 2s on mobile. The combination of image prioritization, font optimization, and script deferral removed the biggest render-blocking bottlenecks.
- ›CLS dropped from 0.18 to under 0.05. Proper image dimensions, font metric adjustment, and reserved space for injected elements eliminated virtually all layout shifts.
- ›INP dropped from ~350ms to under 150ms. Script deferral freed the main thread, and
startTransitionkept filtering interactions responsive.
The PageSpeed Insights score moved from the 40-55 range to consistently above 90 on mobile. The site passed all three Core Web Vitals thresholds in CrUX data within a month as field data accumulated.
The business outcomes followed. Google search rankings recovered for key product category pages. The marketing team reported that organic traffic trends reversed. The improved load times also appeared to correlate with a reduction in cart abandonment during checkout, though isolating the exact contribution of performance from other concurrent changes is always difficult.
What I'd Do Differently
Start with field data, not lab data. I initially optimized based on Lighthouse scores in development, which runs on a fast machine with a simulated throttled connection. Some optimizations that looked impactful in lab settings had minimal effect in the field, and vice versa. I would start with CrUX data and focus on what real users are actually experiencing.
Automate third-party script auditing. Third-party scripts are the biggest ongoing threat to performance. Marketing teams add tracking pixels and widgets without consulting engineering. I would set up automated Lighthouse CI checks that fail the build if a new third-party script is detected that has not been explicitly approved and configured with the correct loading strategy.
Implement resource hints more aggressively. <link rel="preconnect"> for critical third-party origins and <link rel="dns-prefetch"> for non-critical ones can shave 100-200ms off connection setup. I added these late in the project and wish I had included them from the start.
Profile on real low-end devices earlier. Testing on a MacBook Pro with Chrome DevTools throttling is not the same as testing on a $150 Android phone on a 3G connection. I would establish a real-device testing protocol at the beginning of any performance project.
FAQ
Why are Core Web Vitals important for e-commerce?
A 100ms delay in load time can result in a 7% drop in conversions. Google also uses Web Vitals as a ranking factor, so poor performance directly impacts both revenue and organic traffic. For e-commerce specifically, the financial impact is multiplicative: slower pages mean lower search rankings (less traffic), higher bounce rates (fewer shoppers), and more cart abandonment (fewer conversions). Every millisecond of delay compounds through the funnel. Beyond Google's ranking algorithm, users have been trained by fast sites like Amazon to expect instant page loads, and they will leave for a competitor if your site feels sluggish.
What are the most common Web Vitals bottlenecks on e-commerce sites?
The three biggest culprits are blocking third-party scripts (analytics, tracking pixels), unoptimized hero images, and large font payloads that delay rendering. E-commerce sites are uniquely vulnerable because they tend to accumulate third-party scripts over time: analytics, remarketing pixels, A/B testing tools, chat widgets, email capture popups, and review widgets. Each one adds JavaScript to parse, network requests to complete, and DOM mutations that cause layout shifts. Product images are another common bottleneck because e-commerce sites rely heavily on high-quality photography, and without proper optimization (responsive sizes, modern formats, priority loading), these images become the LCP bottleneck.
How do you optimize Largest Contentful Paint in Next.js?
Use the priority attribute on hero images, load fonts with next/font for automatic optimization, and defer non-critical third-party scripts using next/script with the lazyOnload strategy. The priority prop on the Next.js Image component does two critical things: it adds fetchpriority="high" to the image element and injects a <link rel="preload"> tag in the document head, ensuring the browser begins downloading the hero image as early as possible. Combined with proper sizes attributes to avoid downloading oversized images, and next/font to eliminate render-blocking font requests, these three changes typically address the most impactful LCP bottlenecks on any Next.js e-commerce site.
Collaboration
Need help with a project?
Let's Build It
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.
Related Articles
Building an AI-Powered Interview Feedback System
How we built an AI-powered system that analyzes mock interview recordings and generates structured feedback on communication, technical accuracy, and problem-solving approach using LLMs.
Migrating from Pages to App Router
A detailed post-mortem on migrating a massive enterprise dashboard from Next.js Pages Router to the App Router.
Performance Optimization for an Image-Heavy Rental Platform
How we optimized Core Web Vitals for an image-heavy rental platform, reducing LCP from 4.2s to 1.8s through responsive images, lazy loading, blur placeholders, and CDN-based image transformation.