Performance No. 295Editor's pick

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.

  • namecore-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

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.

~/.claude/skills/core-web-vitals/SKILL.md

--- name: core-web-vitals description: Diagnoses and fixes Core Web Vitals — LCP, INP, and CLS — starting from field data at the 75th percentile (CrUX, Search Console, web-vitals… license: MIT ---

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)

MetricGoodNeeds improvementPoorMeasures
LCP≤ 2.5 s≤ 4.0 s> 4.0 sRender time of the largest image/text block in the viewport
INP≤ 200 ms≤ 500 ms> 500 msWorst-ish interaction latency (input to next paint) over the visit
CLS≤ 0.1≤ 0.25> 0.25Largest 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

SourceGranularityFreshness
PageSpeed Insights (top section)URL and origin, mobile/desktop28-day rolling CrUX
Search Console Core Web Vitals reportURL groups by template28-day rolling, lagged
CrUX API / CrUX History APIURL or origin, weekly historyWeekly
Your RUM (web-vitals library)Per page view with attributionReal 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> --view for 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 dominantTypical causeFix
TTFB (> 40% of LCP)Slow server, no CDN cache, redirectsCache HTML at edge, remove redirect chains, stream SSR, 103 Early Hints
Resource load delayLCP 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 durationHuge image, slow originRight-size, AVIF/WebP, responsive srcset, image CDN
Element render delayRender-blocking CSS/JS, client-side rendering, web font blocking text, hidden until JSInline critical CSS, defer JS, SSR the hero, font-display: swap/optional

INP = input delay + processing duration + presentation delay.

INP sub-part dominantTypical causeFix
Input delayMain thread busy with other tasks (hydration, third-party scripts, timers)Split long tasks, delay non-critical third parties, reduce hydration work
Processing durationHeavy event handlers, synchronous state updates re-rendering large treesDo visible update first, yield, defer the rest; memoize/virtualize
Presentation delayLarge DOM, expensive style/layout, forced synchronous layoutShrink 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 causeFix
Images/iframes/video without dimensionswidth/height attributes or CSS aspect-ratio
Ads, embeds, banners injected above contentReserve space with min-height; insert below the fold or overlay
Web fonts swapping metricsfont-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/heightAnimate 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 (unload handlers, 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

  1. Lab: 3–5 Lighthouse or DevTools runs under the same throttling; compare medians of LCP, TBT, CLS before and after.
  2. 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.
  3. RUM: p75 for the affected template and device class moves into “good” within days.
  4. 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 125 lines · 5.6 KB
# 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.

esc