Core Web Vitals skill for Claude Code
Fix the sub-part that is actually slow. Claude starts from field data at the 75th percentile, not a Lighthouse score, and splits the failing metric into its sub-parts: TTFB and load delay for LCP, input delay and processing for INP, the shifting element for CLS. It then applies the one fix that targets the dominant sub-part, verifies it under matching throttling, and tells you when CrUX will reflect the change.
- name
core-web-vitals - size190 lines · ≈2,200 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Diagnoses and fixes Core Web Vitals — LCP, INP, and CLS — starting from field data at the 75th percentile (CrUX, Search Console, web-vitals RUM with attribution), breaking each metric into its sub-parts, and applying targeted fixes such as fetchpriority, preload, long-task splitting with scheduler.yield, and reserved layout space. Use when PageSpeed Insights or Search Console flags poor or needs-improvement URLs, when LCP, INP, or CLS regress, when the site feels slow to load or janky to interact with, or when setting up RUM for vitals. Try asking
- Search Console says our product pages have poor LCP on mobile — find and fix the cause
- INP is 450 ms on the search page; figure out which interaction and why
- Set up web-vitals RUM with attribution and tell me which template is worst
Use it when
- PageSpeed Insights or Search Console flags poor vitals
- LCP, INP, or CLS regressed after a release
- Pages feel slow to load or janky on interaction
- Adding real-user monitoring for vitals
Skip it when
- The problem is purely JavaScript bundle weight — use bundle-analysis
- You want CI gates on Lighthouse scores — use lighthouse-ci
- Image formats and responsive markup are the whole task — use image-optimization
How to check the result
Median of several throttled lab runs shows the targeted metric improved, then RUM p75 for the affected template and device class reaches the good threshold.
The common mistake it prevents
Optimizing for a green Lighthouse score while CrUX stays poor, because lab runs miss real devices, logged-in states and interactions that drive INP.
Done well means: the failing metric is identified from field data at p75 for the right device class, its dominant sub-part is measured, the fix targets that sub-part, and the improvement is confirmed first in the lab and then in field data 28 days (CrUX) or one RUM cycle later.
Thresholds (p75 of page loads, mobile and desktop judged separately)
| Metric | Good | Needs improvement | Poor | Measures |
|---|---|---|---|---|
| LCP | ≤ 2.5 s | ≤ 4.0 s | > 4.0 s | Render time of the largest image/text block in the viewport |
| INP | ≤ 200 ms | ≤ 500 ms | > 500 ms | Worst-ish interaction latency (input to next paint) over the visit |
| CLS | ≤ 0.1 | ≤ 0.25 | > 0.25 | Largest burst (session window) of unexpected layout shifts |
INP replaced FID in March 2024. TTFB (good ≤ 0.8 s) and FCP (≤ 1.8 s) are diagnostics, not Core Web Vitals.
Ground rules
- Field data decides whether there is a problem; lab data explains why. A green Lighthouse score with poor CrUX means real users are on slower devices, networks, or different pages/states than your lab run.
- Fix the metric that is failing, on the template that is failing, on the device class that is failing. Mobile is usually the binding constraint.
- Change one thing at a time and re-measure. Many “optimizations” (lazy-loading everything, preloading everything) make vitals worse.
- Lighthouse cannot measure INP (no interactions); use Total Blocking Time only as a rough lab proxy, and test interactions manually or with RUM.
Workflow
- [ ] 1. Get field data: which metric, which URL group, which device, how far from "good"
- [ ] 2. Reproduce in the lab with matching conditions (throttled mobile, cold cache)
- [ ] 3. Break the metric into sub-parts and find the dominant one
- [ ] 4. Apply the targeted fix for that sub-part
- [ ] 5. Verify in the lab (same conditions, 3–5 runs, median)
- [ ] 6. Ship, then confirm in RUM within days and CrUX within 28 days
1. Field data sources
| Source | Granularity | Freshness |
|---|---|---|
| PageSpeed Insights (top section) | URL and origin, mobile/desktop | 28-day rolling CrUX |
| Search Console Core Web Vitals report | URL groups by template | 28-day rolling, lagged |
| CrUX API / CrUX History API | URL or origin, weekly history | Weekly |
Your RUM (web-vitals library) | Per page view with attribution | Real time |
If a URL lacks CrUX data (low traffic), use origin-level data and RUM.
RUM with attribution (send to your analytics endpoint):
import { onLCP, onINP, onCLS, onTTFB } from "web-vitals/attribution";
function send(metric) {
const body = JSON.stringify({
name: metric.name, value: metric.value, rating: metric.rating, id: metric.id,
page: location.pathname, attribution: metric.attribution, // sub-parts and target element
});
(navigator.sendBeacon && navigator.sendBeacon("/rum", body)) ||
fetch("/rum", { body, method: "POST", keepalive: true });
}
onLCP(send); onINP(send); onCLS(send); onTTFB(send);
Aggregate at p75 per template and device class; never report averages.
2. Lab reproduction
- Chrome DevTools Performance panel: record a reload with CPU 4x–6x slowdown and a slow 4G network preset; the live metrics view shows LCP/CLS/INP locally and can show CrUX values for comparison.
- Lighthouse in an incognito window or
npx lighthouse <url> --viewfor a mobile run. - Test logged-out and logged-in states, cookie banner shown and dismissed, and the most-visited page states — field data includes all of them.
3. Sub-parts and the dominant cause
LCP = TTFB + resource load delay + resource load duration + element render delay.
| LCP sub-part dominant | Typical cause | Fix |
|---|---|---|
| TTFB (> 40% of LCP) | Slow server, no CDN cache, redirects | Cache HTML at edge, remove redirect chains, stream SSR, 103 Early Hints |
| Resource load delay | LCP image discovered late (CSS background, JS-inserted, loading="lazy") | Put <img> in HTML, fetchpriority="high", <link rel="preload"> for background images, never lazy-load the LCP image |
| Resource load duration | Huge image, slow origin | Right-size, AVIF/WebP, responsive srcset, image CDN |
| Element render delay | Render-blocking CSS/JS, client-side rendering, web font blocking text, hidden until JS | Inline critical CSS, defer JS, SSR the hero, font-display: swap/optional |
INP = input delay + processing duration + presentation delay.
| INP sub-part dominant | Typical cause | Fix |
|---|---|---|
| Input delay | Main thread busy with other tasks (hydration, third-party scripts, timers) | Split long tasks, delay non-critical third parties, reduce hydration work |
| Processing duration | Heavy event handlers, synchronous state updates re-rendering large trees | Do visible update first, yield, defer the rest; memoize/virtualize |
| Presentation delay | Large DOM, expensive style/layout, forced synchronous layout | Shrink DOM, content-visibility: auto, avoid layout thrash, batch reads then writes |
CLS: find the shifting element (largestShiftTarget in attribution, or the Layout
Shifts track in DevTools) and the cause.
| CLS cause | Fix |
|---|---|
| Images/iframes/video without dimensions | width/height attributes or CSS aspect-ratio |
| Ads, embeds, banners injected above content | Reserve space with min-height; insert below the fold or overlay |
| Web fonts swapping metrics | font-display: optional, or metric-matched fallback with size-adjust/ascent-override |
| Content loaded after render (late API data) | Skeleton with final dimensions |
Animations of top/left/height | Animate transform and opacity only |
For the full fix catalog with code, read references/fix-catalog.md once you know which sub-part dominates.
4. The fixes with the biggest payoff
LCP image:
<link rel="preconnect" href="https://images.example-cdn.com" crossorigin>
<img src="/hero-1200.avif"
srcset="/hero-800.avif 800w, /hero-1200.avif 1200w, /hero-1800.avif 1800w"
sizes="(max-width: 800px) 100vw, 1200px"
width="1200" height="630" alt="..."
fetchpriority="high" decoding="async">
Breaking up a long task (INP):
function yieldToMain() {
if (globalThis.scheduler?.yield) return scheduler.yield(); // Chromium 129+
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function onFilterChange(e) {
updateCheckboxUI(e.target); // visible feedback first
await yieldToMain(); // let the browser paint
const results = applyFilters(items); // heavy work after the paint
await yieldToMain();
renderResults(results);
queueMicrotask(() => sendAnalytics("filter")); // or schedule off the critical path
}
Third parties: load tag managers, chat widgets, and A/B testing scripts after the load event or on interaction, and audit them with DevTools’ third-party filter. A/B testing tools that hide the page until they run hurt LCP directly.
Gotchas
loading="lazy"on the LCP image (common in CMS templates and image components) adds hundreds of ms of load delay.- Preloading many resources competes with the LCP resource; preload only what the LCP element needs.
- Client-side rendered hero content cannot paint until JS downloads, parses, and runs.
- CLS is counted for the whole visit, including shifts after scrolling and in SPAs on route changes; lab tools only capture load.
- bfcache restores give near-zero LCP/CLS; pages blocked from bfcache (
unloadhandlers,Cache-Control: no-store) lose that boost. Check DevTools > Application > Back/forward cache. - Cookie consent banners are frequent LCP elements and CLS sources; make them overlays with fixed size.
- React/Next.js: hydration is a long task; heavy client components above the fold inflate INP for early interactions.
Verification
- Lab: 3–5 Lighthouse or DevTools runs under the same throttling; compare medians of LCP, TBT, CLS before and after.
- Interaction check: DevTools Performance recording of the slow interaction shows no task over 50 ms between input and next paint, or INP in the live metrics view ≤ 200 ms.
- RUM: p75 for the affected template and device class moves into “good” within days.
- CrUX/Search Console: confirm after the 28-day window; note the deploy date.
Report template
**Scope:** /product/* on mobile (Search Console group "Product pages")
**Field before:** LCP p75 3.9 s (needs improvement), INP 180 ms, CLS 0.04
**Dominant sub-part:** resource load delay 1.6 s — hero image lazy-loaded via JS carousel
**Fix:** server-render first slide as <img fetchpriority="high">, removed lazy loading, AVIF srcset
**Lab after (median of 5, Moto G Power profile):** LCP 4.4 s -> 2.3 s
**Field check:** RUM p75 2.4 s after 7 days; CrUX re-check scheduled for 2026-11-20 Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/fix-catalog.md
# Core Web Vitals fix catalog
Grouped by metric sub-part. Apply only the fixes that match the measured dominant
sub-part.
## LCP — TTFB
- Cache HTML at the CDN for anonymous traffic (`Cache-Control: public, s-maxage=60,
stale-while-revalidate=600`); vary only on what truly changes the page.
- Remove redirect chains (`http` -> `https` -> `www` -> trailing slash). Each hop on
mobile can cost 100–300 ms.
- Stream server rendering so the `<head>` (with preloads) flushes before data fetching
completes.
- Send `103 Early Hints` with `Link: <...>; rel=preload` for the LCP image and critical
CSS when the CDN supports it.
- Check server timing with `Server-Timing` headers to split DB, render, and upstream time.
## LCP — resource load delay
- The LCP image must be in the initial HTML as an `<img>` (or `<picture>`), not injected
by JS or set via CSS `background-image`.
- If it must be a CSS background: `<link rel="preload" as="image" href="..." fetchpriority="high">`,
with `imagesrcset`/`imagesizes` for responsive variants.
- `fetchpriority="high"` on the LCP `<img>`; leave other images at default.
- Never `loading="lazy"` on above-the-fold images. In Next.js `<Image>` set `priority`
(or `preload` in newer versions — check the installed version's docs).
- `preconnect` to the image CDN origin if it differs from the document origin (max 2–3
preconnects).
- Text LCP: ensure the font does not block render (`font-display: swap` or `optional`),
and preload the one font file used by the LCP text.
## LCP — resource load duration
- Serve AVIF/WebP with a fallback via `<picture>` or CDN content negotiation.
- Size images to the rendered size times device pixel ratio (cap at 2x for photos).
- Compress: AVIF quality ~50–60, WebP ~75 are common starting points; compare visually.
- Use HTTP/2 or HTTP/3; avoid sharding.
## LCP — element render delay
- Inline critical CSS (< 14 KB compressed) and load the rest asynchronously, or keep
CSS small enough to be fine render-blocking.
- `defer` or `type="module"` for scripts; no synchronous scripts in `<head>` before the
LCP element.
- Server-render the LCP element. In SPAs, prerender or SSR the above-the-fold shell.
- Remove anti-flicker snippets (A/B testing) that hide `<body>` until JS runs, or limit
them to the tested element with a short timeout.
- Avoid entrance animations on the LCP element (opacity 0 until JS runs delays LCP).
## INP — input delay
- Find long tasks during the interaction window: DevTools Performance panel, Long
Animation Frames (LoAF) entries in RUM (`attribution.longAnimationFrameEntries` in
web-vitals v4+).
- Defer third-party scripts; load chat widgets on click; use `requestIdleCallback` for
analytics initialization.
- Reduce hydration cost: island architecture, server components, lazy hydration of
below-the-fold components.
- Avoid `setInterval` work on the main thread every few ms; move polling to a worker.
## INP — processing duration
- Update the UI that acknowledges the interaction first, then yield
(`scheduler.yield()` with a `setTimeout` fallback), then do the rest.
- React: wrap non-urgent updates in `startTransition` / `useTransition`; use
`useDeferredValue` for expensive derived lists; memoize large subtrees.
- Debounce input handlers that trigger expensive work (search-as-you-type: 150–300 ms).
- Move pure computation (parsing, sorting large arrays, diffing) to a Web Worker.
- Avoid synchronous `localStorage` access with large values in handlers.
## INP — presentation delay
- Keep DOM size reasonable (Lighthouse warns above ~1,400 nodes; aim lower for complex
pages). Virtualize long lists.
- `content-visibility: auto` with `contain-intrinsic-size` on off-screen sections.
- Avoid forced synchronous layout: do all DOM reads (`offsetHeight`,
`getBoundingClientRect`) before writes in the same frame.
- Prefer CSS classes toggled on a small subtree over style changes on `<body>`/`<html>`
that invalidate everything.
## CLS
- Every `<img>`, `<video>`, `<iframe>` has `width` and `height` (the browser derives
aspect ratio) or a CSS `aspect-ratio`.
- Ad slots: reserve the most common size with `min-height`; collapse only after the
user scrolls past or never.
- Fonts: `font-display: optional` eliminates swap shifts (font used only if ready);
otherwise define a fallback `@font-face` with `size-adjust`, `ascent-override`,
`descent-override` to match metrics (tools like Fontaine or next/font do this).
- Insert dynamic content (banners, "new posts" bars) as overlays or below the viewport;
shifts within 500 ms of a user input are excluded, so content revealed by a click is fine.
- Animate with `transform`; `top`, `left`, `width`, `height`, `margin` changes cause shifts.
- SPAs: route transitions that render a short skeleton then grow cause shifts; reserve
final dimensions.
## Debugging snippets
Log LCP element and timing in the console:
```js
new PerformanceObserver((list) => {
const e = list.getEntries().at(-1);
console.log("LCP", Math.round(e.startTime), e.element, e.url);
}).observe({ type: "largest-contentful-paint", buffered: true });
```
Log layout shifts with the shifted nodes:
```js
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (!e.hadRecentInput) console.log("shift", e.value.toFixed(4), e.sources?.map((s) => s.node));
}
}).observe({ type: "layout-shift", buffered: true });
```
Log slow interactions:
```js
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (e.interactionId && e.duration > 200) console.log("slow", e.name, Math.round(e.duration), e.target);
}
}).observe({ type: "event", buffered: true, durationThreshold: 16 });
```
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.