After three months of complaints about sluggish page loads, we ran a Chrome Lighthouse audit and discovered our LCP hovered around 4.2 seconds. CLS jumped between 0.15 and 0.28 depending on viewport size, and INP spiked past 400ms whenever someone clicked a filter dropdown. Not ideal. Here's what we changed to bring LCP under two seconds, flatten CLS to 0.03, and drop INP below 200ms—all tested on production traffic across mobile and desktop.
What slowed down LCP in the first place
Largest Contentful Paint measures how long your biggest above-the-fold element takes to render. In our case, it was a hero image served from origin without cache headers, sitting below three render-blocking stylesheets and two synchronous JavaScript files. The browser had to download CSS, parse it, fetch fonts, then finally request the 1.2 MB JPEG. By the time pixels appeared, four seconds had elapsed.
Three culprits surfaced immediately: no CDN edge cache for images, no preload hints for critical assets, and images served at full resolution regardless of device. Fixing LCP meant tackling all three.
Push images to a CDN with smart cache rules
We moved static assets to a CDN with origin pull enabled and set cache TTL to 30 days for images and fonts. On Cloudflare, that's a page rule scoped to *.jpg, *.png, *.woff2 with "Cache Level: Cache Everything" and "Edge Cache TTL: 1 month." Other CDNs offer similar controls—KeyCDN, BunnyCDN, and Fastly all let you define cache behavior per path pattern.
After the CDN change, repeat visits loaded the hero image in under 300ms because edge nodes served it instead of round-tripping to origin. First-time visitors still waited while the CDN fetched from origin, but subsequent requests hit cache. Instant win for returning users.
Preload the LCP image and critical fonts
Preload hints tell the browser to fetch resources early, before the HTML parser stumbles on them naturally. We added two lines inside <head>:
<link rel="preload" as="image" href="/images/hero.jpg" fetchpriority="high">
<link rel="preload" as="font" href="/fonts/inter-var.woff2" type="font/woff2" crossorigin>
The fetchpriority="high" attribute (supported in Chrome 101+, Firefox 119+, Safari 17.2+) tells the browser this image matters more than others. Fonts need crossorigin even when served from the same domain because font requests are always CORS.
LCP dropped from 4.2 seconds to 2.8 seconds after preloading. Not done yet, but progress.
Serve responsive images with srcset
Our 1.2 MB JPEG looked identical at 1920px and 375px width because we shipped one giant file. Responsive images let the browser pick the right size:
<img src="/images/hero-800.jpg"
srcset="/images/hero-400.jpg 400w,
/images/hero-800.jpg 800w,
/images/hero-1200.jpg 1200w,
/images/hero-1920.jpg 1920w"
sizes="(max-width: 600px) 400px,
(max-width: 1200px) 800px,
1200px"
alt="Hero image"
width="1200"
height="630">
Generate those variants with ImageMagick or your CDN's image optimization service. Cloudflare Images, Imgix, and Cloudinary all resize on the fly. Mobile users downloaded 120 KB instead of 1.2 MB. LCP on phones fell under two seconds.
Desktop still sat at 2.4 seconds because of render-blocking CSS. Time to tackle that.
Cut render-blocking resources
Every stylesheet and synchronous script in <head> delays first paint. We had three CSS files: a theme framework, custom styles, and an icon font. Two JavaScript files loaded jQuery and a carousel library, both blocking.
Inline critical CSS and defer the rest
Critical CSS is the subset of styles needed to render above-the-fold content. Extract it with a tool like Critical or Penthouse, then inline it in <head> and load the full stylesheet asynchronously:
<style>
/* Inlined critical CSS here (5-10 KB typical) */
body { margin: 0; font-family: Inter, sans-serif; }
.hero { min-height: 400px; background: #f0f0f0; }
</style>
<link rel="preload" as="style" href="/css/full.css" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/css/full.css"></noscript>
This pattern (preload + onload swap) loads the full stylesheet without blocking render. First paint happened 800ms earlier.
Move scripts to the footer or use defer
JavaScript in <head> without defer or async stops HTML parsing. We moved non-critical scripts to the end of <body> and added defer to the rest:
<script src="/js/analytics.js" defer></script>
<script src="/js/interactions.js" defer></script>
defer downloads the script in parallel with HTML parsing but waits to execute until the DOM is ready. async executes immediately after download, which can still block render if it runs early. We used defer for everything except analytics (which got async because order doesn't matter).
LCP dropped to 1.9 seconds on desktop, 1.7 on mobile. Close enough to call it done.
Eliminate cumulative layout shift
CLS measures how much visible content jumps around during load. Ours came from three sources: images without dimensions, a late-loading web font, and an ad slot that expanded after the DOM rendered.
Reserve space with width and height attributes
Images without explicit dimensions cause layout shift when they load because the browser doesn't know how much space to reserve. Modern browsers map width and height to aspect ratio automatically:
<img src="/images/product.jpg" width="800" height="600" alt="Product photo">
CSS keeps it responsive:
img {
max-width: 100%;
height: auto;
}
The browser calculates aspect ratio (800/600 = 1.333) and reserves the right space before the image loads. No jump.
Use font-display: swap to show text immediately
Web fonts can cause FOIT (flash of invisible text) or FOUT (flash of unstyled text) while loading. FOIT blocks text rendering entirely, which hurts both LCP and CLS when the font finally swaps in. We set font-display: swap in our @font-face rule:
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
font-display: swap;
}
swap shows fallback text immediately and swaps to the web font when ready. The brief flash is better than invisible text or layout shift from font size differences. Preloading the font (as shown earlier) reduces swap time to under 100ms.
Set min-height on dynamic content containers
Our sidebar ad slot loaded asynchronously and pushed content down by 250 pixels when it appeared. We fixed it by reserving space:
.ad-slot {
min-height: 250px;
background: #f5f5f5;
}
If the ad fails to load, the empty space remains visible instead of collapsing. Trade-off: wasted space if ads don't fill. Acceptable for us because CLS dropped to 0.03.
Reduce interaction to next paint
INP measures responsiveness—how fast the page reacts when you click, tap, or type. Our filter dropdowns fired a full page re-render on every keystroke, and event handlers weren't debounced. Bad news for INP.
Debounce expensive event handlers
Debouncing delays execution until the user stops interacting. Our search filter triggered an AJAX request on every keypress:
// Before: fires 12 times if you type "web hosting"
input.addEventListener('input', (e) => {
fetchResults(e.target.value);
});
After debouncing:
function debounce(fn, delay) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => fn.apply(this, args), delay);
};
}
input.addEventListener('input', debounce((e) => {
fetchResults(e.target.value);
}, 300));
Now it fires once after the user pauses for 300ms. INP dropped from 410ms to 180ms on the search page.
Avoid long tasks that block the main thread
Long tasks (anything over 50ms) freeze the UI. We had a CSV export function that looped through 5,000 rows synchronously. Moved it to a Web Worker:
// worker.js
self.onmessage = (e) => {
const csv = generateCSV(e.data.rows);
self.postMessage(csv);
};
Main thread:
const worker = new Worker('/js/worker.js');
worker.postMessage({ rows: data });
worker.onmessage = (e) => {
downloadCSV(e.data);
};
UI stayed responsive during export. INP on that page went from 520ms to 140ms.
How we validated the improvements
Lighthouse in Chrome DevTools gave us initial numbers, but real user metrics matter more. We enabled Chrome User Experience Report data in Google Search Console and checked field metrics weekly. LCP, CLS, and INP all stayed green (good thresholds) for 28 days after the changes shipped.
PageSpeed Insights shows both lab and field data. Field data reflects actual user experience across devices and network conditions. Lab data is useful for debugging but doesn't capture real-world variability.
Common questions
How long until Core Web Vitals affect rankings?
Google collects 28 days of field data before using it in ranking. Changes won't move your position overnight.
Do all three metrics matter equally?
Google weights them equally in ranking, but user impact differs. LCP affects perceived speed, CLS affects trust (nobody likes jumpy pages), and INP affects interactivity. Fix the worst one first.
Can I ignore desktop if mobile is green?
Google uses mobile metrics for ranking, but desktop experience still matters for conversions. We optimized both.
Will a CDN alone fix LCP?
Not always. If your images are huge or render-blocking resources delay paint, a CDN only speeds up delivery. You still need preloading, responsive images, and critical CSS.
What to check first
Run a Lighthouse audit and look at the opportunities list. Start with the biggest time savings—usually images, render-blocking CSS, and unused JavaScript. Preload your LCP element, set explicit dimensions on images, and defer non-critical scripts. Measure field data for 28 days before calling it done. Real users on slow networks see different bottlenecks than synthetic tests.
These seven fixes dropped our LCP from 4.2 to 1.8 seconds, flattened CLS to 0.03, and cut INP in half. No plugin required, just configuration and a bit of refactoring. Your mileage will vary depending on hosting, CMS, and asset size, but the principles hold: preload what matters, reserve layout space, and keep the main thread free.
