Diagnose and improve Core Web Vitals (LCP / CLS / INP). Includes a Mermaid decision tree for root-cause triage across all three vitals + canonical fix-by-symptom map. Field data (CrUX / RUM) at p75 is the measurement target. Used by `performance-engineer` (primary).
Installs into .claude/skills of the current project.
Are you the author of Core Web Vitals Tuning?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/mcorbett51090-core-web-vitals-tuning)
---
name: core-web-vitals-tuning
description: Diagnose and improve Core Web Vitals (LCP / CLS / INP). Includes a Mermaid decision tree for root-cause triage across all three vitals + canonical fix-by-symptom map. Field data (CrUX / RUM) at p75 is the measurement target. Used by `performance-engineer` (primary).
---
# Skill: core-web-vitals-tuning
**Purpose:** Diagnose and improve Core Web Vitals. Used by `performance-engineer` (primary).
## When to use
- CrUX / RUM data shows CWV regression
- Lighthouse audit flags CWV issues
- Pre-launch budget enforcement
- Slow-page complaint from users / stakeholders
- New feature added; verify no perf regression
## The three vitals
| Vital | Threshold (Good) | What it measures |
|---|---|---|
| **LCP** (Largest Contentful Paint) | ≤ 2.5s | Time to render the largest visible element above the fold |
| **CLS** (Cumulative Layout Shift) | ≤ 0.1 | Unexpected layout movement across the page lifecycle |
| **INP** (Interaction to Next Paint) | ≤ 200ms | Worst-case interaction-response time (replaces FID since 2024) |
Field data (CrUX / RUM) at the **75th percentile** is the standard measurement target.
## Decision Tree: CWV root-cause triage
**When this applies:** any CWV regression that needs diagnosis before fix-selection. **Traverse this tree top-to-bottom before picking a fix** — diagnosing the wrong vital wastes the budget.
**Last verified:** 2026-09-23 against the HTTP Archive Web Almanac 2025 Performance chapter, the `web-features` npm package (3.39.0 — `scheduler.yield` ships Chrome/Edge 129+ and Firefox 142+, **not Safari**; `requestIdleCallback` also absent from Safari), and React's CHANGELOG (React 19.3.0, 2026-09-09).
```mermaid
flowchart TD
START[CWV regression reported] --> Q1{"Which vital is failing?<br/>Check CrUX p75"}
Q1 -->|LCP > 2.5s| LCP_Q{What's the LCP element?}
Q1 -->|CLS > 0.1| CLS_Q{When does shift occur?}
Q1 -->|INP > 200ms| INP_Q{Which interaction is slow?}
LCP_Q -->|Image| LCP_IMG["Preload + fetchpriority + WebP/AVIF<br/>+ explicit width/height"]
LCP_Q -->|Text + late web font| LCP_FONT["font-display:swap<br/>+ subset + preload"]
LCP_Q -->|Render-blocking JS / CSS| LCP_JS["Defer JS + critical-CSS inline<br/>+ remove unused CSS"]
LCP_Q -->|Server slow TTFB > 800ms| LCP_TTFB["CDN cache + server-render<br/>+ edge function if dynamic"]
CLS_Q -->|Image / video / iframe| CLS_DIMS["Reserve space:<br/>width+height attrs OR aspect-ratio CSS"]
CLS_Q -->|Late web font swap| CLS_FONT["Match metrics: size-adjust<br/>+ ascent/descent override"]
CLS_Q -->|Ad / embed injected late| CLS_AD["Reserve container min-height<br/>+ lazy-load below the fold"]
CLS_Q -->|Animation triggers reflow| CLS_ANIM["Use transform/opacity only<br/>NOT width/height/top/left"]
INP_Q -->|Heavy JS on click handler| INP_JS["Split work with scheduler.yield<br/>guarded — falls back to setTimeout on Safari<br/>(no scheduler, no requestIdleCallback there)"]
INP_Q -->|Synchronous third-party| INP_3P["Defer/async load<br/>or remove the script"]
INP_Q -->|Large React re-render| INP_REACT["Memoize + virtualize lists<br/>+ React 19 transitions / React Compiler"]
INP_Q -->|Slow paint after JS| INP_PAINT["Use will-change:transform<br/>OR composite-only properties"]
```
**Rationale per leaf:**
- *LCP_IMG* — most common LCP cause. Preload + fetchpriority=high gets the bytes into the browser first; WebP/AVIF cuts bytes 30-50%; explicit dimensions prevent reflow that pushes LCP further out.
- *LCP_FONT* — `font-display:swap` shows fallback immediately; the swap-in is a CLS risk but typically the LCP win is bigger. If CLS becomes an issue, use `size-adjust` to match metrics.
- *LCP_JS* — render-blocking is the silent LCP killer. `defer` on non-critical JS, inline critical CSS, audit with Coverage tab in DevTools.
- *LCP_TTFB* — if TTFB > 800ms, fix the server first. Static / CDN cache for marketing pages. Edge function for dynamic. SSR is fine; CSR is the LCP enemy.
- *CLS_DIMS* — the single highest-impact CLS fix. Every `<img>`, `<video>`, `<iframe>` gets explicit width + height OR aspect-ratio.
- *CLS_FONT* — `size-adjust` + `ascent-override` + `descent-override` match the fallback metrics to the web font so swap-in is invisible.
- *CLS_AD* — late-injected ads or embeds shift everything below. Reserve container `min-height`; lazy-load if below the fold.
- *CLS_ANIM* — animating `width` / `height` / `top` / `left` triggers reflow; animating `transform` / `opacity` is composited and doesn't shift layout.
- *INP_JS* — long tasks block the main thread. `scheduler.yield()` chunks the work in Chrome/Edge 129+ and Firefox 142+, but it is **not supported in Safari** (which also lacks `requestIdleCallback`) — feature-detect and fall back to `setTimeout(fn, 0)` rather than calling `scheduler.yield()` unguarded (it throws a `ReferenceError` in Safari).
- *INP_3P* — synchronous third-party scripts (chat widgets, A/B test SDKs) are common INP killers. Defer/async OR remove.
- *INP_REACT* — large lists virtualize (React Virtuoso, TanStack Virtual); state updates that cascade memoize with `useMemo`/`useCallback`/`React.memo`, or lean on React Compiler's automatic memoization (1.0 shipped 2025-10-07) where enabled; React 19.3 (2026-09-09) also made transitions render independently instead of entangling, which helps interaction responsiveness during concurrent updates.
- *INP_PAINT* — slow paint after JS often means the layer isn't composited. `will-change: transform` hints the browser to promote.
**Tradeoffs summary:**
| Vital | Default fix | Time to fix | Risk |
|---|---|---|---|
| LCP (image) | Preload + fetchpriority + WebP/AVIF | 1-2 hours | Low — well-supported |
| LCP (font) | font-display:swap | 30 min | Medium — may cause CLS if metrics don't match |
| LCP (TTFB) | CDN / static rendering | 4-8 hours | High — may require platform change |
| CLS (dimensions) | width/height attributes everywhere | 2-4 hours | Low |
| CLS (font swap) | size-adjust / ascent-override | 1-2 hours | Low |
| INP (heavy JS) | scheduler.yield + chunking | 4-12 hours | Medium — refactor scope |
| INP (third-party) | Remove or defer | 30 min — 4 hours | Depends on stakeholder pushback |
**Failure modes to avoid:**
- Diagnosing the wrong vital. If CrUX shows LCP > 2.5s but INP is fine, fix LCP first. Don't burn the budget on the wrong vital.
- Treating Lighthouse lab data as field truth. Field (CrUX / RUM) at p75 is the standard target; lab data is a debugging tool, not a goal.
- Optimizing for the median when p75 is what gets graded.
- Fixing CLS by hiding content (visibility:hidden) — that just moves the problem; users still see broken layout.
## LCP — fix-by-symptom
### Symptom: LCP is an image
Most common cause. The fix:
1. Identify the LCP element (Chrome DevTools → Performance → Web Vitals lane).
2. Add `<link rel="preload" as="image" href="..." fetchpriority="high">` in `<head>`.
3. Add `fetchpriority="high"` to the `<img>` itself.
4. Remove `loading="lazy"` from the LCP image.
5. Serve it in modern format (AVIF / WebP).
6. Right-size it (responsive `srcset` + `sizes`); don't ship a 4000px image to a 400px slot.
7. Compress aggressively (90-quality WebP is usually indistinguishable from 100).
8. Serve via image CDN.
### Symptom: LCP is text, blocked by web font
1. Self-host fonts (or use a fast CDN).
2. `font-display: swap` (or `optional`).
3. Preload critical fonts: `<link rel="preload" href="..." as="font" type="font/woff2" crossorigin>`.
4. Subset fonts (Latin only when content is Latin; drop unused weights).
5. Reduce number of weights / styles loaded.
### Symptom: LCP blocked by render-blocking CSS
1. Inline critical CSS (above-the-fold styles) in `<head>`.
2. Defer non-critical CSS via `<link rel="preload" as="style" onload="this.rel='stylesheet'">` pattern.
3. Avoid `@import` chains.
### Symptom: LCP blocked by render-blocking JS
1. `defer` or `async` every non-essential script.
2. Move analytics / chat / A/B-test scripts to load post-interaction or post-LCP.
3. Code-split the framework if possible (Astro / Next handle this; vanilla SPAs need manual care).
### Symptom: Server response slow (TTFB > 600ms)
1. Move to a CDN-edge-rendered platform (Vercel / Netlify / Cloudflare Pages).
2. Static-generate pages (SSG) where possible; SSR + edge caching otherwise.
3. Investigate backend / DB latency (escalate to `ravenclaude-core/backend-coder`).
## CLS — fix-by-symptom
### Symptom: Image shifts in
1. Always specify `width` and `height` (or `aspect-ratio`) on `<img>`.
2. Use `aspect-ratio` CSS for responsive containers.
### Symptom: Web font swaps in, text reflows
1. `font-display: optional` (no swap; user without font cached sees system font; LCP not affected by font).
2. Or `font-display: swap` + `size-adjust` / `ascent-override` / `descent-override` on `@font-face` to match system-font metrics.
### Symptom: Ad / embed loads in late, pushes content down
1. Reserve space with `min-height` or `aspect-ratio` for the ad / embed container.
2. Defer ad / embed load until below-the-fold visibility (intersection observer).
### Symptom: Element animated using `width` / `height` / `top` / `left`
1. Animate `transform` and `opacity` instead.
2. Add `will-change: transform` where genuinely needed (don't sprinkle widely).
### Symptom: Cookie banner / notification jumps content
1. Position as overlay (fixed / absolute) so it doesn't reflow content.
2. Or reserve space for it from initial render.
## INP — fix-by-symptom
### Symptom: Click handler runs long task on main thread
1. Break long tasks into smaller chunks (yield to main thread with `await new Promise(r => setTimeout(r, 0))` or `scheduler.yield()`).
2. Move CPU work to a Web Worker.
3. Debounce / throttle high-frequency handlers.
### Symptom: React re-renders too much on interaction
1. Memoize expensive components.
2. Move state down (lift fewer state changes high in the tree).
3. Use `useDeferredValue` / `useTransition` for non-urgent updates.
### Symptom: Hydration on a partially-static site
1. Astro islands / Qwik / partial hydration.
2. Don't hydrate components that don't need interactivity.
### Symptom: Third-party script blocks main thread
1. Catalogue every third-party script + its main-thread cost.
2. Defer non-critical (analytics, chat, A/B test) until after Idle.
3. Replace with first-party where possible.
## Measurement tooling
- **CrUX**: PageSpeed Insights for field data
- **RUM**: web-vitals.js library, Vercel Speed Insights, Cloudflare Web Analytics, custom integration
- **Lab**: Lighthouse (DevTools / CLI), WebPageTest (slow 3G + Moto G4 simulates real mobile)
**Field beats lab.** Always show field data in audit reports when available.
## Performance budget
Set per-page budgets:
| Metric | Marketing site | Product app |
|---|---|---|
| LCP (P75) | ≤ 2.5s | ≤ 2.5s |
| CLS (P75) | ≤ 0.05 | ≤ 0.1 |
| INP (P75) | ≤ 200ms | ≤ 200ms |
| Page weight (transfer) | ≤ 1 MB | ≤ 2 MB |
| JS bundle (compressed) | ≤ 100 KB | ≤ 250 KB |
| Image budget | ≤ 500 KB | ≤ 1 MB |
| Third-party scripts | ≤ 3 | ≤ 5 |
Enforce in CI where possible (Lighthouse CI, bundle-size action).
## See also
- Template: [`../../templates/performance-budget.md`](../../templates/performance-budget.md)
- Agent: [`../../agents/performance-engineer.md`](../../agents/performance-engineer.md)