Core Web Vitals in Practice: A Full Retrospective Cutting LCP from 4.2s to 1.1s
A real performance optimization log: measure the baseline, localize the bottleneck, optimize each item — images, fonts, JS execution, layout shift — with a reusable performance budget checklist.
Summary: Performance work fails when you “optimize by feel”. This post follows a real optimization, prioritized LCP → INP → CLS, with measurement methods and copyable code for every step.
Step 1: Establish a baseline
Don’t change anything yet. Gather data with three tools:
- Lighthouse: lab benchmark, repeatable
- Chrome DevTools Performance: localize the exact bottleneck
- Web Vitals JS library: real-user data
The baseline at the time (mobile, 4G):
LCP 4.2s ← largest contentful paint, the main target
INP 310ms ← interaction delay, medium target
CLS 0.18 ← layout shift, over budget
Step 2: Localize the LCP bottleneck
The LCP element was a 1.2MB PNG above the fold. The Performance waterfall revealed three problems:
- The image was 2400px wide but only displayed at 640px
- Font loading blocked rendering (
display: block) - First-paint JS was dominated by an analytics script
Step 3: Optimize item by item
3.1 Images: size, format, loading timing
<!-- Before -->
<img src="hero.png" alt="banner" />
<!-- After: responsive + modern format + async decode -->
<img
src="hero-640.webp"
srcset="hero-640.webp 640w, hero-1280.webp 1280w"
sizes="(max-width: 768px) 100vw, 640px"
alt="banner"
fetchpriority="high"
decoding="async"
/>
Gain: image transfer dropped from 1.2MB to 48KB — LCP cut in half.
3.2 Fonts: preload the critical weights
<link
rel="preload"
href="/fonts/inter-latin-400.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap"
/>
display=swap plus preloading only the weights actually used avoids FOIT and extra font requests.
3.3 Layout shift: reserve space for dynamic content
CLS over budget usually comes from “inserting content that pushes existing content”. Reserve minimum heights for dynamic areas:
.skeleton {
aspect-ratio: 16 / 9;
background: linear-gradient(90deg, #eee, #fafafa, #eee);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
Step 4: Verify and budget
Re-measured after optimization:
LCP 1.1s (↓ 74%)
INP 95ms (↓ 69%)
CLS 0.02 (✓ within budget)
To keep the score from regressing, add Lighthouse CI assertions:
{
"ci": {
"assert": {
"assertions": {
"largest-contentful-paint": ["warn", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-blocking-time": ["error", { "maxNumericValue": 200 }]
}
}
}
}
A reusable performance budget checklist
- No image above 100KB on first paint (WebP/AVIF)
- Fonts use
display=swapand only include used weights - First-paint JS ≤ 50KB gzip; third-party scripts all
defer - All images and expandable content reserve space (
aspect-ratio/min-height) - Lighthouse assertions in CI — fail the build on over-budget
Wrap-up
Performance optimization is “measure → localize → optimize → lock in”. A budget in CI beats any verbal agreement. Every number that drops comes from a concrete, reproducible change — not a blind rewrite.