---
name: tech-leads-club/core-web-vitals
source: https://app.decimal.ai/s/tech-leads-club-core-web-vitals@1/SKILL.md
source_sha256: 5081730fbeab
---

# Core Web Vitals optimization

Targeted optimization for the three Core Web Vitals metrics that affect Google Search ranking and user experience.

## The three metrics

| Metric  | Measures         | Good    | Needs work    | Poor    |
| ------- | ---------------- | ------- | ------------- | ------- |
| **LCP** | Loading          | ≤ 2.5s  | 2.5s – 4s     | > 4s    |
| **INP** | Interactivity    | ≤ 200ms | 200ms – 500ms | > 500ms |
| **CLS** | Visual Stability | ≤ 0.1   | 0.1 – 0.25    | > 0.25  |

Google measures at the **75th percentile** — 75% of page visits must meet "Good" thresholds.

---

## LCP: Largest Contentful Paint

LCP measures when the largest visible content element renders. Usually this is:

- Hero image or video
- Large text block
- Background image
- `<svg>` element

### Common LCP issues

**1. Slow server response (TTFB > 800ms)**

```
Fix: CDN, caching, optimized backend, edge rendering
```

**2. Render-blocking resources**

```html
<!-- ❌ Blocks rendering -->
<link rel="stylesheet" href="/all-styles.css" />

<!-- ✅ Critical CSS inlined, rest deferred -->
<style>
  /* Critical above-fold CSS */
</style>
<link rel="preload" href="/styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'" />
```

**3. Slow resource load times**

```html
<!-- ❌ No hints, discovered late -->
<img src="/hero.jpg" alt="Hero" />

<!-- ✅ Preloaded with high priority -->
<link rel="preload" href="/hero.webp" as="image" fetchpriority="high" />
<img src="/hero.webp" alt="Hero" fetchpriority="high" />
```

**4. Client-side rendering delays**

```javascript
// ❌ Content loads after JavaScript
useEffect(() => {
  fetch('/api/hero-text')
    .then((r) => r.json())
    .then(setHeroText)
}, [])

// ✅ Server-side or static rendering
// Use SSR, SSG, or streaming to send HTML with content
export async function getServerSideProps() {
  const heroText = await fetchHeroText()
  return { props: { heroText } }
}
```

### LCP optimization checklist

```markdown
- [ ] TTFB < 800ms (use CDN, edge caching)
- [ ] LCP image preloaded with fetchpriority="high"
- [ ] LCP image optimized (WebP/AVIF, correct size)
- [ ] Critical CSS inlined (< 14KB)
- [ ] No render-blocking JavaScript in <head>
- [ ] Fonts don't block text rendering (font-display: swap)
- [ ] LCP element in initial HTML (not JS-rendered)
```

### LCP element identification

```javascript
// Find your LCP element
new PerformanceObserver((list) => {
  const entries = list.getEntries()
  const lastEntry = entries[entries.length - 1]
  console.log('LCP element:', lastEntry.element)
  console.log('LCP time:', lastEntry.startTime)
}).observe({ type: 'largest-contentful-paint', buffered: true })
```

---

## INP: Interaction to Next Paint

INP measures responsiveness across ALL interactions (clicks, taps, key presses) during a page visit. It reports the worst interaction (at 98th percentile for high-traffic pages).

### INP breakdown

Total INP = **Input Delay** + **Processing Time** + **Presentation Delay**

| Phase        | Target  | Optimization                |
| ------------ | ------- | --------------------------- |
| Input Delay  | < 50ms  | Reduce main thread blocking |
| Processing   | < 100ms | Optimize event handlers     |
| Presentation | < 50ms  | Minimize rendering work     |

### Common INP issues

**1. Long tasks blocking main thread**

```javascript
// ❌ Long synchronous task
function processLargeArray(items) {
  items.forEach((item) => expensiveOperation(item))
}

// ✅ Break into chunks with yielding
async function processLargeArray(items) {
  const CHUNK_SIZE = 100
  for (let i = 0; i < items.length; i += CHUNK_SIZE) {
    const chunk = items.slice(i, i + CHUNK_SIZE)
    chunk.forEach((item) => expensiveOperation(item))

    // Yield to main thread
    await new Promise((r) => setTimeout(r, 0))
    // Or use scheduler.yield() when available
  }
}
```

**2. Heavy event handlers**

```javascript
// ❌ All work in handler
button.addEventListener('click', () => {
  // Heavy computation
  const result = calculateComplexThing()
  // DOM updates
  updateUI(result)
  // Analytics
  trackEvent('click')
})

// ✅ Prioritize visual feedback
button.addEventListener('click', () => {
  // Immediate visual feedback
  button.classList.add('loading')

  // Defer non-critical work
  requestAnimationFrame(() => {
    const result = calculateComplexThing()
    updateUI(result)
  })

  // Use requestIdleCallback for analytics
  requestIdleCallback(() => trackEvent('click'))
})
```

**3. Third-party scripts**

```javascript
// ❌ Eagerly loaded, blocks interactions
;<script src="https://heavy-widget.com/widget.js"></script>

// ✅ Lazy loaded on interaction or visibility
const loadWidget = () => {
  import('https://heavy-widget.com/widget.js').then((widget) => widget.init())
}
button.addEventListener('click', loadWidget, { once: true })
```

**4. Excessive re-renders (React/Vue)**

```javascript
// ❌ Re-renders entire tree
function App() {
  const [count, setCount] = useState(0)
  return (
    <div>
      <Counter count={count} />
      <ExpensiveComponent /> {/* Re-renders on every count change */}
    </div>
  )
}

// ✅ Memoized expensive components
const MemoizedExpensive = React.memo(ExpensiveComponent)

function App() {
  const [count, setCount] = useState(0)
  return (
    <div>
      <Counter count={count} />
      <MemoizedExpensive />
    </div>
  )
}
```

### INP optimization checklist

```markdown
- [ ] No tasks > 50ms on main thread
- [ ] Event handlers complete quickly (< 100ms)
- [ ] Visual feedback provided immediately
- [ ] Heavy work deferred with requestIdleCallback
- [ ] Third-party scripts don't block interactions
- [ ] Debounced input handlers where appropriate
- [ ] Web Workers for CPU-intensive operations
```

### INP debugging

```javascript
// Identify slow interactions
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 200) {
      console.warn('Slow interaction:', {
        type: entry.name,
        duration: entry.duration,
        processingStart: entry.processingStart,
        processingEnd: entry.processingEnd,
        target: entry.target,
      })
    }
  }
}).observe({ type: 'event', buffered: true, durationThreshold: 16 })
```

---

## CLS: Cumulative Layout Shift

CLS measures unexpected layout shifts. A shift occurs when a visible element changes position between frames without user interaction.

**CLS Formula:** `impact fraction × distance fraction`

### Common CLS causes

**1. Images without dimensions**

```html
<!-- ❌ Causes layout shift when loaded -->
<img src="photo.jpg" alt="Photo" />

<!-- ✅ Space reserved -->
<img src="photo.jpg" alt="Photo" width="800" height="600" />

<!-- ✅ Or use aspect-ratio -->
<img src="photo.jpg" alt="Photo" style="aspect-ratio: 4/3; width: 100%;" />
```

**2. Ads, embeds, and iframes**

```html
<!-- ❌ Unknown size until loaded -->
<iframe src="https://ad-network.com/ad"></iframe>

<!-- ✅ Reserve space with min-height -->
<div style="min-height: 250px;">
  <iframe src="https://ad-network.com/ad" height="250"></iframe>
</div>

<!-- ✅ Or use aspect-ratio container -->
<div style="aspect-ratio: 16/9;">
  <iframe src="https://youtube.com/embed/..." style="width: 100%; height: 100%;"></iframe>
</div>
```

**3. Dynamically injected content**

```javascript
// ❌ Inserts content above viewport
notifications.prepend(newNotification)

// ✅ Insert below viewport or use transform
const insertBelow = viewport.bottom < newNotification.top
if (insertBelow) {
  notifications.prepend(newNotification)
} else {
  // Animate in without shifting
  newNotification.style.transform = 'translateY(-100%)'
  notifications.prepend(newNotification)
  requestAnimationFrame(() => {
    newNotification.style.transform = ''
  })
}
```

**4. Web fonts causing FOUT**

```css
/* ❌ Font swap shifts text */
@font-face {
  font-family: 'Custom';
  src: url('custom.woff2') format('woff2');
}

/* ✅ Optional font (no shift if slow) */
@font-face {
  font-family: 'Custom';
  src: url('custom.woff2') format('woff2');
  font-display: optional;
}

/* ✅ Or match fallback metrics */
@font-face {
  font-family: 'Custom';
  src: url('custom.woff2') format('woff2');
  font-display: swap;
  size-adjust: 105%; /* Match fallback size */
  ascent-override: 95%;
  descent-override: 20%;
}
```

**5. Animations triggering layout**

```css
/* ❌ Animates layout properties */
.animate {
  transition:
    height 0.3s,
    width 0.3s;
}

/* ✅ Use transform instead */
.animate {
  transition: transform 0.3s;
}
.animate.expanded {
  transform: scale(1.2);
}
```

### CLS optimization checklist

```markdown
- [ ] All images have width/height or aspect-ratio
- [ ] All videos/embeds have reserved space
- [ ] Ads have min-height containers
- [ ] Fonts use font-display: optional or matched metrics
- [ ] Dynamic content inserted below viewport
- [ ] Animations use transform/opacity only
- [ ] No content injected above existing content
```

### CLS debugging

```javascript
// Track layout shifts
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) {
      console.log('Layout shift:', entry.value)
      entry.sources?.forEach((source) => {
        console.log('  Shifted element:', source.node)
        console.log('  Previous rect:', source.previousRect)
        console.log('  Current rect:', source.currentRect)
      })
    }
  }
}).observe({ type: 'layout-shift', buffered: true })
```

---

## Measurement tools

### Lab testing

- **Chrome DevTools** → Performance panel, Lighthouse
- **WebPageTest** → Detailed waterfall, filmstrip
- **Lighthouse CLI** → `npx lighthouse <url>`

### Field data (real users)

- **Chrome User Experience Report (CrUX)** → BigQuery or API
- **Search Console** → Core Web Vitals report
- **web-vitals library** → Send to your analytics

```javascript
import { onLCP, onINP, onCLS } from 'web-vitals'

function sendToAnalytics({ name, value, rating }) {
  gtag('event', name, {
    event_category: 'Web Vitals',
    value: Math.round(name === 'CLS' ? value * 1000 : value),
    event_label: rating,
  })
}

onLCP(sendToAnalytics)
onINP(sendToAnalytics)
onCLS(sendToAnalytics)
```

---

## Framework quick fixes

### Next.js

```jsx
// LCP: Use next/image with priority
import Image from 'next/image'
;<Image src="/hero.jpg" priority fill alt="Hero" />

// INP: Use dynamic imports
const HeavyComponent = dynamic(() => import('./Heavy'), { ssr: false })

// CLS: Image component handles dimensions automatically
```

### React

```jsx
// LCP: Preload in head
;<link rel="preload" href="/hero.jpg" as="image" fetchpriority="high" />

// INP: Memoize and useTransition
const [isPending, startTransition] = useTransition()
startTransition(() => setExpensiveState(newValue))

// CLS: Always specify dimensions in img tags
```

### Vue/Nuxt

```vue
<!-- LCP: Use nuxt/image with preload -->
<NuxtImg src="/hero.jpg" preload loading="eager" />

<!-- INP: Use async components -->
<component :is="() => import('./Heavy.vue')" />

<!-- CLS: Use aspect-ratio CSS -->
<img :style="{ aspectRatio: '16/9' }" />
```

## References

- [web.dev LCP](https://web.dev/articles/lcp)
- [web.dev INP](https://web.dev/articles/inp)
- [web.dev CLS](https://web.dev/articles/cls)
- [Performance skill](../performance/SKILL.md)