I've spent the last two years neck-deep in performance optimization, and I can tell you that Core Web Vitals are both simpler and more complicated than most articles make them sound. The metrics themselves are straightforward. Getting them to pass in production? That's where things get messy.
The E-Commerce Wake-Up Call
Last year, I audited a mid-sized e-commerce site that was bleeding mobile conversions. Analytics showed users bouncing at rates we'd never seen before. The culprit wasn't obvious - the site felt fast on our development machines. But the Core Web Vitals told a different story:
- LCP: 5.2 seconds (target: under 2.5s)
- CLS: 0.41 (target: under 0.1)
- INP: 387ms (target: under 200ms)
Here's what we learned fixing it.
LCP: The Image Priority Paradox
The site's hero image—a 1920x800px banner at the top of every product page—was the LCP element. And the development team had proudly lazy-loaded it "for performance." This is one of those things that sounds right but is exactly backwards.
Here's what they had:
<img src="/hero-banner.jpg"
loading="lazy"
alt="Summer Sale">The problem: loading="lazy" tells the browser "this isn't important, load it whenever." For your LCP element, that's the opposite of what you want. Here's what we changed it to:
<link rel="preload"
as="image"
href="/hero-banner.jpg"
fetchpriority="high">
<img src="/hero-banner.jpg"
fetchpriority="high"
alt="Summer Sale"
width="1920"
height="800">But there's more to it. Modern browsers are smart—they start loading images as soon as they see them in the HTML. But if your image is defined in CSS or loaded by JavaScript, the browser doesn't know about it until much later. We moved critical images from CSS background-image to actual <img> tags and saw LCP drop by 1.8 seconds.
The real optimization came from responsive images:
<img srcset="/hero-banner-400.jpg 400w,
/hero-banner-800.jpg 800w,
/hero-banner-1200.jpg 1200w,
/hero-banner-1920.jpg 1920w"
sizes="100vw"
src="/hero-banner-1920.jpg"
fetchpriority="high"
alt="Summer Sale"
width="1920"
height="800">Mobile users were downloading a 400KB desktop hero image when they only needed a 80KB mobile version. This change alone cut LCP in half on mobile devices.
CLS: The Layout Shift Murder Mystery
Layout shift is insidious because it happens after the user thinks the page is loaded. They start reading, they go to click something, and BOOM—an ad loads and shifts everything down 300 pixels. They click the wrong thing. They get frustrated. They leave.
Our biggest CLS offender was third-party ad slots. The markup looked like this:
<div class="ad-slot">
<!-- Ad loads asynchronously -->
</div>.ad-slot {
/* No height defined */
}When the ad loaded, it pushed everything below it down. The fix isn't complicated, but it requires knowing your ad dimensions:
.ad-slot {
/* Reserve space for a 300x250 ad */
min-height: 250px;
aspect-ratio: 300 / 250;
}
/* For responsive ads */
.ad-slot-responsive {
aspect-ratio: 16 / 9;
width: 100%;
}But here's where it gets tricky: we also found layout shift from web fonts. The site used custom fonts that loaded after the initial render, causing text to reflow. The solution was font-display: swap combined with font preloading:
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preload"
as="font"
href="/fonts/custom-font.woff2"
type="font/woff2"
crossorigin>@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom-font.woff2') format('woff2');
font-display: swap;
/* This prevents invisible text while loading */
font-display: optional; /* Or use this to avoid layout shift entirely */
}Actually, font-display: optional worked better for us. It tells the browser: "Use this font if it's already cached, otherwise just use the system font." No layout shift, no flash of unstyled text. The trade-off is some users never see your custom font on first visit, but the CLS improvement was worth it.
INP: The Metric Nobody Talks About
Interaction to Next Paint (INP) replaced First Input Delay (FID) in 2024, and it's a harder metric to optimize. INP measures how long it takes the browser to respond to all user interactions, not just the first one.
We had a product filtering system that felt laggy. Users would click a filter checkbox and nothing would happen for half a second. The code looked fine:
// The original implementation
filterCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', (e) => {
updateProductList(); // This was synchronous and blocking
});
});The problem was updateProductList() was doing too much work on the main thread. We refactored it to be asynchronous:
filterCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', async (e) => {
// Show loading state immediately
setLoadingState(true);
// Use requestIdleCallback to defer non-critical work
requestIdleCallback(() => {
updateFilters(e.target);
});
// Schedule the heavy work
await updateProductList();
setLoadingState(false);
});
});But that wasn't enough. The real issue was we were re-rendering 500+ product cards on every filter change. We implemented virtual scrolling and only rendered what was visible:
function renderVisibleProducts() {
const scrollTop = container.scrollTop;
const containerHeight = container.clientHeight;
const itemHeight = 300; // Average card height
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.ceil((scrollTop + containerHeight) / itemHeight);
// Only render items in viewport + small buffer
const visibleProducts = allProducts.slice(
Math.max(0, startIndex - 5),
Math.min(allProducts.length, endIndex + 5)
);
renderProducts(visibleProducts);
}INP dropped from 387ms to 143ms. Users could finally filter products without the interface freezing.
Lab Data vs. Field Data: The Reality Check
Here's the thing about Lighthouse: it's a controlled environment. It's like testing your car on a closed track. Real users are out there on bumpy roads, in bad weather, with three kids in the back seat screaming.
We had perfect Lighthouse scores—95+ across the board. But our Chrome UX Report (CrUX) data showed a different story. Real users in rural areas with 3G connections were seeing LCP times of 8+ seconds.
We started tracking real user metrics with the web-vitals library:
import {onCLS, onINP, onLCP} from 'web-vitals';
function sendToAnalytics({name, value, rating, navigationType}) {
// Send to your analytics endpoint
fetch('/analytics', {
method: 'POST',
body: JSON.stringify({
metric: name,
value: value,
rating: rating,
navigationType: navigationType,
url: window.location.href,
connection: navigator.connection?.effectiveType,
timestamp: Date.now()
})
});
}
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);This revealed that our third-party analytics scripts were destroying performance on slow connections. We ended up:
- Self-hosting critical scripts instead of loading from third-party CDNs
- Using a service worker to cache scripts aggressively
- Loading non-critical analytics with async or defer
- Implementing request prioritization based on connection speed:
if ('connection' in navigator) {
const connection = navigator.connection;
if (connection.effectiveType === '4g') {
// Load high-quality images, all analytics
loadFullExperience();
} else if (connection.effectiveType === '3g') {
// Load medium-quality images, essential analytics only
loadOptimizedExperience();
} else {
// Load low-quality images, no non-essential scripts
loadMinimalExperience();
}
}What Actually Moved the Needle
After three months of optimization, here's what made the biggest difference:
For LCP:
- Serving appropriately sized images (40% improvement)
- Using fetchpriority="high" on hero images (25% improvement)
- Removing render-blocking JavaScript (20% improvement)
For CLS:
- Setting explicit dimensions on images and ads (eliminated 90% of shifts)
- Using font-display: optional for web fonts (eliminated remaining shifts)
- Avoiding dynamic content injection above the fold
For INP:
- Virtual scrolling for long lists (60% improvement)
- Debouncing expensive operations (30% improvement)
- Moving heavy computations to Web Workers (20% improvement)
The site's mobile conversion rate increased by 23% after these changes. Not because we made it "faster" in some abstract sense, but because we removed the friction that was driving users away.
The Honest Truth
Core Web Vitals optimization isn't glamorous work. It's finding that one third-party script that's blocking your main thread. It's arguing with stakeholders about removing that auto-playing video. It's explaining why that giant hero carousel is killing your mobile performance.
But it works. And unlike a lot of web development trends, this one actually makes the web better for users.
