{"id":"core-web-vitals","name":"core-web-vitals","summary":"より良いページ体験と検索順位のために、コアウェブバイタル(LCP、INP、CLS)を最適化しましょう。","body":"# Core Web Vitals optimization\n\nTargeted optimization for the three Core Web Vitals metrics that affect Google Search ranking and user experience.\n\n## The three metrics\n\n| Metric | Measures | Good | Needs work | Poor |\n|--------|----------|------|------------|------|\n| **LCP** | Loading | ≤ 2.5s | 2.5s – 4s | > 4s |\n| **INP** | Interactivity | ≤ 200ms | 200ms – 500ms | > 500ms |\n| **CLS** | Visual Stability | ≤ 0.1 | 0.1 – 0.25 | > 0.25 |\n\nGoogle measures at the **75th percentile** — 75% of page visits must meet \"Good\" thresholds.\n\n---\n\n## LCP: Largest Contentful Paint\n\nLCP measures when the largest visible content element renders. Usually this is:\n- Hero image or video\n- Large text block\n- Background image\n- `<svg>` element\n\n### Common LCP issues\n\n**1. Slow server response (TTFB > 800ms)**\n```\nFix: CDN, caching, optimized backend, edge rendering\n```\n\n**2. Render-blocking resources**\n```html\n<!-- ❌ Blocks rendering -->\n<link rel=\"stylesheet\" href=\"/all-styles.css\">\n\n<!-- ✅ Critical CSS inlined, rest deferred -->\n<style>/* Critical above-fold CSS */</style>\n<link rel=\"preload\" href=\"/styles.css\" as=\"style\" \n      onload=\"this.onload=null;this.rel='stylesheet'\">\n```\n\n**3. Slow resource load times**\n```html\n<!-- ❌ No hints, discovered late -->\n<img src=\"/hero.jpg\" alt=\"Hero\">\n\n<!-- ✅ Preloaded with high priority -->\n<link rel=\"preload\" href=\"/hero.webp\" as=\"image\" fetchpriority=\"high\">\n<img src=\"/hero.webp\" alt=\"Hero\" fetchpriority=\"high\">\n```\n\n**4. Client-side rendering delays**\n```javascript\n// ❌ Content loads after JavaScript\nuseEffect(() => {\n  fetch('/api/hero-text').then(r => r.json()).then(setHeroText);\n}, []);\n\n// ✅ Server-side or static rendering\n// Use SSR, SSG, or streaming to send HTML with content\nexport async function getServerSideProps() {\n  const heroText = await fetchHeroText();\n  return { props: { heroText } };\n}\n```\n\n**5. Make navigations instant with the Speculation Rules API**\n\nFor most sites, the LCP a user actually experiences is dominated by *the next page they navigate to*, not the one they landed on. Telling the browser to prerender likely-next pages on hover collapses that LCP to ~0ms.\n\n```html\n<script type=\"speculationrules\">\n{\n  \"prerender\": [{\n    \"where\": { \"href_matches\": \"/*\" },\n    \"eagerness\": \"moderate\"\n  }]\n}\n</script>\n```\n\n`eagerness` settings (cheapest → most aggressive): `conservative` (start on pointerdown), `moderate` (start after ~200ms hover), `eager` (start as soon as the link is in the viewport), `immediate` (start on page load). Start with `moderate` — it captures most navigations without prerendering pages users never visit.\n\nCaveats:\n- **Bandwidth/CPU cost.** Each prerender is roughly a full page load. Scope `where` carefully (`href_matches` patterns, exclude logout/checkout) and avoid `immediate` outside small sites.\n- **Side effects fire early.** Analytics, ads, and any code that runs on load will fire when the prerender starts, not when the user navigates. Gate side effects on the [`prerenderingchange` event](https://developer.chrome.com/docs/web-platform/prerender-pages#detect_when_a_page_is_prerendered_or_used_for_a_full_navigation) or `document.prerendering`.\n- **Chromium-only.** Safari and Firefox ignore the script — it's a progressive enhancement, never a regression.\n\n### LCP optimization checklist\n\n```markdown\n- [ ] TTFB < 800ms (use CDN, edge caching)\n- [ ] LCP image preloaded with fetchpriority=\"high\"\n- [ ] LCP image optimized (WebP/AVIF, correct size)\n- [ ] Critical CSS inlined (< 14KB)\n- [ ] No render-blocking JavaScript in <head>\n- [ ] Fonts don't block text rendering (font-display: swap)\n- [ ] LCP element in initial HTML (not JS-rendered)\n- [ ] Speculation Rules added for likely-next navigations (moderate eagerness)\n```\n\n### LCP element identification\n```javascript\n// Find your LCP element\nnew PerformanceObserver((list) => {\n  const entries = list.getEntries();\n  const lastEntry = entries[entries.length - 1];\n  console.log('LCP element:', lastEntry.element);\n  console.log('LCP time:', lastEntry.startTime);\n}).observe({ type: 'largest-contentful-paint', buffered: true });\n```\n\n---\n\n## INP: Interaction to Next Paint\n\nINP 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).\n\n### INP breakdown\n\nTotal INP = **Input Delay** + **Processing Time** + **Presentation Delay**\n\n| Phase | Target | Optimization |\n|-------|--------|--------------|\n| Input Delay | < 50ms | Reduce main thread blocking |\n| Processing | < 100ms | Optimize event handlers |\n| Presentation | < 50ms | Minimize rendering work |\n\n### Common INP issues\n\n**1. Long tasks blocking main thread**\n```javascript\n// ❌ Long synchronous task\nfunction processLargeArray(items) {\n  items.forEach(item => expensiveOperation(item));\n}\n\n// ✅ Break into chunks and yield to the scheduler. scheduler.yield() is the\n//    recommended modern API — its continuation is queued at a boosted\n//    priority so the rest of your work resumes ahead of unrelated tasks,\n//    while still letting the browser handle pending input first.\nasync function processLargeArray(items) {\n  const CHUNK_SIZE = 100;\n  for (let i = 0; i < items.length; i += CHUNK_SIZE) {\n    items.slice(i, i + CHUNK_SIZE).forEach(expensiveOperation);\n\n    if ('scheduler' in window && 'yield' in scheduler) {\n      await scheduler.yield();\n    } else {\n      // Fallback for browsers without scheduler.yield (Safari, older Firefox).\n      // setTimeout(0) yields but loses priority — your continuation may run\n      // after unrelated tasks the browser picked up in between.\n      await new Promise(r => setTimeout(r, 0));\n    }\n  }\n}\n```\n\n**2. Heavy event handlers**\n```javascript\n// ❌ All work in handler\nbutton.addEventListener('click', () => {\n  // Heavy computation\n  const result = calculateComplexThing();\n  // DOM updates\n  updateUI(result);\n  // Analytics\n  trackEvent('click');\n});\n\n// ✅ Prioritize visual feedback, then yield before doing the heavy work\nbutton.addEventListener('click', async () => {\n  // 1. Immediate visual feedback (cheap DOM update)\n  button.classList.add('loading');\n\n  // 2. Yield so the browser can paint the loading state before we block\n  if ('scheduler' in window && 'yield' in scheduler) {\n    await scheduler.yield();\n  }\n\n  // 3. Now do the heavy work — the user already saw the click register\n  const result = calculateComplexThing();\n  updateUI(result);\n\n  // 4. Lowest-priority work last, when the main thread is idle\n  if ('requestIdleCallback' in window) {\n    requestIdleCallback(() => trackEvent('click'));\n  } else {\n    setTimeout(() => trackEvent('click'), 0);\n  }\n});\n```\n\n**3. Third-party scripts**\n```javascript\n// ❌ Eagerly loaded, blocks interactions\n<script src=\"https://heavy-widget.com/widget.js\"></script>\n\n// ✅ Lazy loaded on interaction or visibility\nconst loadWidget = () => {\n  import('https://heavy-widget.com/widget.js')\n    .then(widget => widget.init());\n};\nbutton.addEventListener('click', loadWidget, { once: true });\n```\n\n**4. Excessive re-renders (React/Vue)**\n```javascript\n// ❌ Re-renders entire tree\nfunction App() {\n  const [count, setCount] = useState(0);\n  return (\n    <div>\n      <Counter count={count} />\n      <ExpensiveComponent /> {/* Re-renders on every count change */}\n    </div>\n  );\n}\n\n// ✅ Memoized expensive components\nconst MemoizedExpensive = React.memo(ExpensiveComponent);\n\nfunction App() {\n  const [count, setCount] = useState(0);\n  return (\n    <div>\n      <Counter count={count} />\n      <MemoizedExpensive />\n    </div>\n  );\n}\n```\n\n### INP optimization checklist\n\n```markdown\n- [ ] No tasks > 50ms on main thread\n- [ ] Event handlers complete quickly (< 100ms)\n- [ ] Visual feedback provided immediately\n- [ ] Heavy work deferred with requestIdleCallback\n- [ ] Third-party scripts don't block interactions\n- [ ] Debounced input handlers where appropriate\n- [ ] Web Workers for CPU-intensive operations\n```\n\n### INP debugging\n```javascript\n// Identify slow interactions. durationThreshold: 40 matches what the\n// web-vitals library uses — 16 (one frame) fires on nearly every interaction\n// and drowns the console; 40 surfaces interactions that are starting to feel\n// sluggish without spamming.\nnew PerformanceObserver((list) => {\n  for (const entry of list.getEntries()) {\n    if (entry.duration > 200) {\n      console.warn('Slow interaction:', {\n        type: entry.name,\n        duration: entry.duration,\n        processingStart: entry.processingStart,\n        processingEnd: entry.processingEnd,\n        target: entry.target\n      });\n    }\n  }\n}).observe({ type: 'event', buffered: true, durationThreshold: 40 });\n```\n\nFor field debugging across real users, prefer the `web-vitals/attribution` build of the [web-vitals library](https://github.com/GoogleChrome/web-vitals) — `onINP()` from that build attaches a `LoAF` (Long Animation Frame) breakdown identifying the longest script and the input/processing/presentation phase that ate the budget.\n\n---\n\n## CLS: Cumulative Layout Shift\n\nCLS measures unexpected layout shifts. A shift occurs when a visible element changes position between frames without user interaction.\n\n**CLS Formula:** `impact fraction × distance fraction`\n\n### Common CLS causes\n\n**1. Images without dimensions**\n```html\n<!-- ❌ Causes layout shift when loaded -->\n<img src=\"photo.jpg\" alt=\"Photo\">\n\n<!-- ✅ Space reserved -->\n<img src=\"photo.jpg\" alt=\"Photo\" width=\"800\" height=\"600\">\n\n<!-- ✅ Or use aspect-ratio -->\n<img src=\"photo.jpg\" alt=\"Photo\" style=\"aspect-ratio: 4/3; width: 100%;\">\n```\n\n**2. Ads, embeds, and iframes**\n```html\n<!-- ❌ Unknown size until loaded -->\n<iframe src=\"https://ad-network.com/ad\"></iframe>\n\n<!-- ✅ Reserve space with min-height -->\n<div style=\"min-height: 250px;\">\n  <iframe src=\"https://ad-network.com/ad\" height=\"250\"></iframe>\n</div>\n\n<!-- ✅ Or use aspect-ratio container -->\n<div style=\"aspect-ratio: 16/9;\">\n  <iframe src=\"https://youtube.com/embed/...\" \n          style=\"width: 100%; height: 100%;\"></iframe>\n</div>\n```\n\n**3. Dynamically injected content**\n```javascript\n// ❌ Inserts content above viewport\nnotifications.prepend(newNotification);\n\n// ✅ Insert below viewport or use transform\nconst insertBelow = viewport.bottom < newNotification.top;\nif (insertBelow) {\n  notifications.prepend(newNotification);\n} else {\n  // Animate in without shifting\n  newNotification.style.transform = 'translateY(-100%)';\n  notifications.prepend(newNotification);\n  requestAnimationFrame(() => {\n    newNotification.style.transform = '';\n  });\n}\n```\n\n**4. Web fonts causing FOUT**\n```css\n/* ❌ Font swap shifts text */\n@font-face {\n  font-family: 'Custom';\n  src: url('custom.woff2') format('woff2');\n}\n\n/* ✅ Optional font (no shift if slow) */\n@font-face {\n  font-family: 'Custom';\n  src: url('custom.woff2') format('woff2');\n  font-display: optional;\n}\n\n/* ✅ Or match fallback metrics */\n@font-face {\n  font-family: 'Custom';\n  src: url('custom.woff2') format('woff2');\n  font-display: swap;\n  size-adjust: 105%; /* Match fallback size */\n  ascent-override: 95%;\n  descent-override: 20%;\n}\n```\n\n**5. Animations triggering layout**\n```css\n/* ❌ Animates layout properties */\n.animate {\n  transition: height 0.3s, width 0.3s;\n}\n\n/* ✅ Use transform instead */\n.animate {\n  transition: transform 0.3s;\n}\n.animate.expanded {\n  transform: scale(1.2);\n}\n```\n\n### CLS optimization checklist\n\n```markdown\n- [ ] All images have width/height or aspect-ratio\n- [ ] All videos/embeds have reserved space\n- [ ] Ads have min-height containers\n- [ ] Fonts use font-display: optional or matched metrics\n- [ ] Dynamic content inserted below viewport\n- [ ] Animations use transform/opacity only\n- [ ] No content injected above existing content\n```\n\n### CLS debugging\n```javascript\n// Track layout shifts\nnew PerformanceObserver((list) => {\n  for (const entry of list.getEntries()) {\n    if (!entry.hadRecentInput) {\n      console.log('Layout shift:', entry.value);\n      entry.sources?.forEach(source => {\n        console.log('  Shifted element:', source.node);\n        console.log('  Previous rect:', source.previousRect);\n        console.log('  Current rect:', source.currentRect);\n      });\n    }\n  }\n}).observe({ type: 'layout-shift', buffered: true });\n```\n\n---\n\n## Measurement tools\n\n### Lab testing\n- **Chrome DevTools** → Performance panel, Lighthouse\n- **WebPageTest** → Detailed waterfall, filmstrip\n- **Lighthouse CLI** → `npx lighthouse <url>`\n\n### Field data (real users)\n- **Chrome User Experience Report (CrUX)** → BigQuery or API\n- **Search Console** → Core Web Vitals report\n- **web-vitals library** → Send to your analytics\n\n```javascript\nimport {onLCP, onINP, onCLS} from 'web-vitals';\n\nfunction sendToAnalytics({name, value, rating}) {\n  gtag('event', name, {\n    event_category: 'Web Vitals',\n    value: Math.round(name === 'CLS' ? value * 1000 : value),\n    event_label: rating\n  });\n}\n\nonLCP(sendToAnalytics);\nonINP(sendToAnalytics);\nonCLS(sendToAnalytics);\n```\n\n---\n\n## Framework quick fixes\n\n### Next.js\n```jsx\n// LCP: Use next/image with priority\nimport Image from 'next/image';\n<Image src=\"/hero.jpg\" priority fill alt=\"Hero\" />\n\n// INP: Use dynamic imports\nconst HeavyComponent = dynamic(() => import('./Heavy'), { ssr: false });\n\n// CLS: Image component handles dimensions automatically\n```\n\n### React\n```jsx\n// LCP: Preload in head\n<link rel=\"preload\" href=\"/hero.jpg\" as=\"image\" fetchpriority=\"high\" />\n\n// INP: Memoize and useTransition\nconst [isPending, startTransition] = useTransition();\nstartTransition(() => setExpensiveState(newValue));\n\n// CLS: Always specify dimensions in img tags\n```\n\n### Vue/Nuxt\n```vue\n<!-- LCP: Use nuxt/image with preload -->\n<NuxtImg src=\"/hero.jpg\" preload loading=\"eager\" />\n\n<!-- INP: Use async components -->\n<component :is=\"() => import('./Heavy.vue')\" />\n\n<!-- CLS: Use aspect-ratio CSS -->\n<img :style=\"{ aspectRatio: '16/9' }\" />\n```\n\n## References\n\n- [web.dev LCP](https://web.dev/articles/lcp)\n- [web.dev INP](https://web.dev/articles/inp)\n- [web.dev CLS](https://web.dev/articles/cls)\n- [Performance skill](../performance/SKILL.md)","author":"@addyosmani","ownerProfile":null,"authorContacts":null,"sourceUrl":"https://github.com/addyosmani/web-quality-skills/tree/main/skills/core-web-vitals","license":"MIT","category":null,"lang":"en","tokens":3570,"stars":0,"calls30d":2,"claimed":false,"visibility":"public","origin":"crawler","version":"0.1.0","createdAt":"2026-08-22","updatedAt":"2026-08-22","files":[{"path":"references/LCP.md","size":4431,"sha256":"4c5043ef7e1ae4058191a51fbfe5121668bd3677842a2ef563a45a9ca6685d92"}],"requires":{"mcp":[],"tools":[]},"safety":{"flags":[],"scannedAt":"2026-08-22","hasScripts":false,"networkEndpoints":["ad-network.com","developer.chrome.com","heavy-widget.com","web.dev","youtube.com"]}}