Performance Optimization
01 / 02

Core Web Vitals & Loading Performance

Core Web Vitals & Loading Performance

LCP, INP, CLS — what Google ranks on, what each one measures, and the concrete levers to move them.

The Three Vitals

Metric  Measures                        Good    Needs    Poor
──────  ──────────────────────────────  ──────  ───────  ──────
LCP     Largest contentful paint        ≤ 2.5s  ≤ 4.0s   > 4.0s
INP     Interaction to next paint       ≤ 200ms ≤ 500ms  > 500ms
CLS     Cumulative layout shift         ≤ 0.1   ≤ 0.25   > 0.25

LCP = when the biggest above-the-fold image/text is painted.
INP = worst slow interaction the user experienced on the page.
CLS = total layout shift score (unexpected element movement).

These are p75 across all page loads — fix the long tail, not the median.

Improving LCP

<!-- Preload the hero image (so it starts downloading with the HTML) -->
<link rel="preload" as="image" href="/hero.webp"
      fetchpriority="high" imagesrcset="/hero.webp 1x, /hero@2x.webp 2x">

<!-- Preconnect to origins you'll hit immediately -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin>

<!-- The LCP image itself: priority loading, no lazy attribute -->
<img src="/hero.webp" alt="" fetchpriority="high" decoding="async"
     width="1200" height="600">

<!-- DON'T lazy-load above-the-fold images — defers LCP -->
<img src="/below-the-fold.webp" loading="lazy" decoding="async"
     width="800" height="400">
  • Inline critical CSS in <head>, defer the rest. Avoids render-blocking stylesheet round-trips.

  • Serve LCP image in modern format (AVIF/WebP) and right size. Next.js <Image> handles this; Astro/SvelteKit have equivalents.

  • Move the hero out of client-side render. SSR/SSG = HTML already contains it; SPA = blank screen until JS hydrates.

  • Cache static assets aggressively (1y immutable). Use a CDN. Origin TTFB is part of LCP.

Improving INP (replaces FID since 2024)

// INP is dominated by long tasks (> 50ms) on the main thread during input.
// Three levers: less work, smaller work, smarter scheduling.

// 1) Break long tasks with scheduler.yield() or setTimeout(0)
async function processMany(items) {
  for (const item of items) {
    handle(item)
    if ('scheduler' in window && 'yield' in scheduler) {
      await scheduler.yield()    // yields to user input, then resumes
    }
  }
}

// 2) Defer non-critical work with requestIdleCallback
requestIdleCallback(() => trackAnalytics(), {timeout: 2000})

// 3) Move pure computation off the main thread
const worker = new Worker('/parse.js')
worker.postMessage(largeJson)
worker.onmessage = e => render(e.data)

// 4) Debounce expensive input handlers
const debouncedSearch = debounce(query => api.search(query), 200)
input.addEventListener('input', e => debouncedSearch(e.target.value))

// 5) Use CSS transitions instead of JS animation on the main thread.
//    `transform` + `opacity` are GPU-accelerated; `width`/`top` are not.

Improving CLS

<!-- Always declare width/height on media — reserves space before load -->
<img src="avatar.jpg" width="48" height="48" alt="">
<iframe src="..." width="640" height="360"></iframe>

<!-- Use aspect-ratio for fluid layouts -->
<style>
  .card-image { aspect-ratio: 16 / 9; width: 100%; object-fit: cover; }
</style>

<!-- Preload custom fonts; use font-display: optional to avoid swap shift -->
<link rel="preload" as="font" type="font/woff2"
      href="/fonts/inter.woff2" crossorigin>
<style>
  @font-face {
    font-family: 'Inter';
    src: url('/fonts/inter.woff2') format('woff2');
    font-display: optional;   /* no FOIT/FOUT shift; falls back if slow */
  }
</style>
  • Reserve space for late-arriving content (ads, embeds, banners). Don't inject above existing content.

  • Avoid `display: none` → DOM insert flicker patterns. Render server-side; hide with CSS until ready.

  • Animations: use `transform` and `opacity`. Layout-triggering properties (top, left, width, height) cause shift.

Measuring

// Field data > lab data. Use the web-vitals library to capture real users.
import {onLCP, onINP, onCLS} from 'web-vitals'

onLCP(metric => send('LCP', metric.value))
onINP(metric => send('INP', metric.value))
onCLS(metric => send('CLS', metric.value))

// Tools cheat-sheet:
//   Lighthouse        — lab synthetic, good for diffs but optimistic
//   PageSpeed Insights — Lighthouse + CrUX field data
//   Chrome DevTools    — Performance panel, breakdown by long tasks
//   web-vitals.js      — ship to your analytics for p75 trend
//   Search Console     — GSC > Core Web Vitals — Google's view of your URLs

Keep your own version of these notes — editable, searchable, and organised by your stack.

Start free