← All posts

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:

  1. The image was 2400px wide but only displayed at 640px
  2. Font loading blocked rendering (display: block)
  3. 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=swap and 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.