AgentSkillsCN

Core Web Vitals

核心网络指标

SKILL.md

Core Web Vitals optimisation

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

The three metrics

MetricMeasuresGoodNeeds workPoor
LCPLoading<= 2.5s2.5s - 4s> 4s
INPInteractivity<= 200ms200ms - 500ms> 500ms
CLSVisual Stability<= 0.10.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, or <svg> element.

Common LCP issues

1. Slow server response (TTFB > 800ms)

Fix: CDN, caching, optimised backend, edge rendering

2. Render-blocking resources

html
<!-- Bad: blocks rendering -->
<link rel="stylesheet" href="/all-styles.css">

<!-- Good: 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
<!-- Bad: no hints, discovered late -->
<img src="/hero.jpg" alt="Hero">

<!-- Good: 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
// Bad: content loads after JavaScript
useEffect(() => {
  fetch('/api/hero-text').then(r => r.json()).then(setHeroText);
}, []);

// Good: server-side or static rendering
// (framework-specific, but the principle is: LCP content should be
// in the initial HTML response, not fetched client-side)

LCP optimisation checklist

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

LCP element identification

javascript
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

PhaseTargetOptimisation
Input Delay< 50msReduce main thread blocking
Processing< 100msOptimise event handlers
Presentation< 50msMinimise rendering work

Common INP issues

1. Long tasks blocking main thread

javascript
// Bad: long synchronous task
function processLargeArray(items) {
  items.forEach(item => expensiveOperation(item));
}

// Good: 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
// Bad: all work in handler
button.addEventListener('click', () => {
  const result = calculateComplexThing();
  updateUI(result);
  trackEvent('click');
});

// Good: prioritise 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
// Bad: eagerly loaded, blocks interactions
<script src="https://heavy-widget.com/widget.js"></script>

// Good: 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
// Bad: re-renders entire tree
function App() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <Counter count={count} />
      <ExpensiveComponent /> {/* Re-renders on every count change */}
    </div>
  );
}

// Good: memoised expensive components
const MemoizedExpensive = React.memo(ExpensiveComponent);

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

INP optimisation checklist

  • 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
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 x distance fraction

Common CLS causes

1. Images without dimensions

html
<!-- Bad: causes layout shift when loaded -->
<img src="photo.jpg" alt="Photo">

<!-- Good: space reserved -->
<img src="photo.jpg" alt="Photo" width="800" height="600">

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

2. Ads, embeds, and iframes

html
<!-- Bad: unknown size until loaded -->
<iframe src="https://ad-network.com/ad"></iframe>

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

<!-- Also good: 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
// Bad: inserts content above viewport
notifications.prepend(newNotification);

// Good: animate in without shifting
newNotification.style.transform = 'translateY(-100%)';
notifications.prepend(newNotification);
requestAnimationFrame(() => {
  newNotification.style.transform = '';
});

4. Web fonts causing FOUT

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

/* Also good: match fallback metrics to reduce shift.
   These values are font-specific and need calculating per font pair.
   Tools like https://screenspan.net/fallback or fontpie can generate them. */
@font-face {
  font-family: 'Custom';
  src: url('custom.woff2') format('woff2');
  font-display: swap;
  size-adjust: 105%;
  ascent-override: 95%;
  descent-override: 20%;
}

5. Animations triggering layout

css
/* Bad: animates layout properties */
.animate { transition: height 0.3s, width 0.3s; }

/* Good: use transform instead (compositor-only, no layout) */
.animate { transition: transform 0.3s; }
.animate.expanded { transform: scale(1.2); }

CLS optimisation checklist

  • 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 fallback metrics
  • Dynamic content inserted below viewport or animated in
  • Animations use transform/opacity only
  • No content injected above existing content

CLS debugging

javascript
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)

Field data (real users): Chrome User Experience Report (CrUX) via BigQuery or API, Search Console Core Web Vitals report, web-vitals library:

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

function sendToAnalytics({ name, value, rating }) {
  // Send to your analytics provider
  console.log(`${name}: ${value} (${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: useTransition for non-urgent updates
const [isPending, startTransition] = useTransition();
startTransition(() => setExpensiveState(newValue));

// CLS: Always specify dimensions in img tags

Vue/Nuxt

html
<!-- 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