{"slug":"core-web-vitals-optimization","title":"Core Web Vitals Optimization: LCP, INP, and CLS","tags":["performance","core-web-vitals","lcp","inp","cls","nextjs","seo"],"agent_summary":"Definitive Core Web Vitals reference covering LCP, INP, and CLS thresholds, diagnosis flow, and concrete optimizations for Next.js applications.","trigger_phrases":["Core Web Vitals","LCP optimization","INP optimization","CLS fix","Largest Contentful Paint","Interaction to Next Paint","Cumulative Layout Shift","page speed"],"runnable":false,"markdown":"\n## Overview\n\nCore Web Vitals are Google's primary user-experience metrics. They directly affect SEO rankings and user retention.\n\n## The Three Metrics\n\n| Metric | Full Name | Good | Needs Work | Poor |\n|--------|-----------|------|------------|------|\n| **LCP** | Largest Contentful Paint | < 2.5s | 2.5-4s | > 4s |\n| **INP** | Interaction to Next Paint | < 200ms | 200-500ms | > 500ms |\n| **CLS** | Cumulative Layout Shift | < 0.1 | 0.1-0.25 | > 0.25 |\n\n**LCP** = how fast the main content appears. **INP** = how responsive the page is to clicks/taps/keys. **CLS** = how much content shifts unexpectedly during load.\n\n## Diagnosis Flow\n\n```\n1. Run PageSpeed Insights (pagespeed.web.dev)\n   → Identifies which metric(s) are failing\n\n2. Run Chrome DevTools Performance tab\n   → See the exact LCP element, INP interaction, or CLS source\n\n3. Fix the root cause (not symptoms)\n\n4. Re-measure — CWV changes take ~28 days to reflect in Search Console\n```\n\n## LCP Optimization\n\nThe LCP element is almost always: hero image, above-the-fold heading, or video poster.\n\n```html\n<!-- Priority: preload LCP image -->\n<link rel=\"preload\" as=\"image\" href=\"/hero.webp\" fetchpriority=\"high\" />\n```\n\n```tsx\n// Next.js Image component — LCP hero\n<Image\n  src=\"/hero.webp\"\n  alt=\"Hero\"\n  width={1200}\n  height={600}\n  priority          // Removes lazy loading for LCP element\n  fetchPriority=\"high\"\n  sizes=\"(max-width: 768px) 100vw, 1200px\"\n/>\n```\n\n**LCP root causes and fixes:**\n\n| Root Cause | Fix |\n|------------|-----|\n| Large unoptimized image | Convert to WebP/AVIF, compress, use `<Image>` |\n| Image not preloaded | Add `priority` or `<link rel=\"preload\">` |\n| Render-blocking resources | Defer non-critical JS, inline critical CSS |\n| Slow TTFB | CDN, edge caching, reduce server response time |\n| No resource hints | Add `dns-prefetch` and `preconnect` for 3rd-party origins |\n\n```html\n<!-- Preconnect to critical third-party origins -->\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin />\n```\n\n## INP Optimization\n\nINP replaced FID in March 2024. It measures the worst-case interaction delay, not just the first.\n\n```typescript\n// Break up long tasks with scheduler.yield()\nasync function processLargeList(items: Item[]) {\n  for (let i = 0; i < items.length; i++) {\n    processItem(items[i]);\n    if (i % 50 === 0) {\n      await scheduler.yield();  // Yield to browser between chunks\n    }\n  }\n}\n\n// Defer non-critical work\nfunction handleClick() {\n  updateUI();              // Critical — do immediately\n  setTimeout(() => {       // Defer analytics/logging\n    logEvent(\"click\");\n  }, 0);\n}\n```\n\n**INP root causes and fixes:**\n\n| Root Cause | Fix |\n|------------|-----|\n| Long tasks (> 50ms) | Break with `scheduler.yield()` or `setTimeout` |\n| Hydration jank | Reduce JS bundle, use React `useTransition` |\n| Synchronous 3rd-party scripts | Load async/defer or move to Web Worker |\n| Heavy event handlers | Debounce, throttle, or move work off main thread |\n\n## CLS Optimization\n\nCLS is caused by content shifting after initial render — images without dimensions, dynamically injected content, web fonts swapping.\n\n```css\n/* Reserve space for images */\nimg, video {\n  aspect-ratio: attr(width) / attr(height);  /* Modern approach */\n}\n\n/* Font swap without layout shift */\n@font-face {\n  font-family: \"Inter\";\n  font-display: optional;  /* \"swap\" causes CLS; \"optional\" doesn't */\n}\n\n/* Reserve space for dynamic content */\n.ad-slot {\n  min-height: 250px;  /* Known ad size */\n}\n```\n\n```tsx\n// Next.js Image — always specify width/height\n<Image src=\"/photo.jpg\" alt=\"\" width={800} height={600} />\n\n// Skeleton placeholder — prevents shift\n{isLoading ? <Skeleton className=\"h-48 w-full\" /> : <Content />}\n```\n\n## Quick Wins Checklist\n\n- [ ] LCP element has `priority` or `fetchpriority=\"high\"`\n- [ ] All images have explicit `width` and `height`\n- [ ] Fonts use `font-display: optional` (or `swap` with explicit dimensions)\n- [ ] No layout shifts from injected ads/embeds (reserved space)\n- [ ] No long tasks > 50ms in click handlers\n- [ ] Third-party scripts loaded with `async` or `defer`\n- [ ] Critical CSS inlined, non-critical deferred\n- [ ] TTFB < 600ms (CDN, caching, edge functions)\n\n## Measurement Tools\n\n| Tool | Best For |\n|------|---------|\n| PageSpeed Insights | Lab + field data, actionable recommendations |\n| Chrome DevTools Performance | Deep LCP/INP diagnosis, flame charts |\n| Search Console Core Web Vitals | Real user data over 28-day window |\n| web-vitals npm package | Real user monitoring in production |\n\n```typescript\n// src/lib/vitals.ts — Report to analytics\nimport { onLCP, onINP, onCLS } from \"web-vitals\";\n\nonLCP(metric => sendToAnalytics(\"LCP\", metric.value));\nonINP(metric => sendToAnalytics(\"INP\", metric.value));\nonCLS(metric => sendToAnalytics(\"CLS\", metric.value));\n```\n","html":"<h2>Overview</h2>\n<p>Core Web Vitals are Google's primary user-experience metrics. They directly affect SEO rankings and user retention.</p>\n<h2>The Three Metrics</h2>\n<p>| Metric | Full Name | Good | Needs Work | Poor |\n|--------|-----------|------|------------|------|\n| <strong>LCP</strong> | Largest Contentful Paint | &#x3C; 2.5s | 2.5-4s | > 4s |\n| <strong>INP</strong> | Interaction to Next Paint | &#x3C; 200ms | 200-500ms | > 500ms |\n| <strong>CLS</strong> | Cumulative Layout Shift | &#x3C; 0.1 | 0.1-0.25 | > 0.25 |</p>\n<p><strong>LCP</strong> = how fast the main content appears. <strong>INP</strong> = how responsive the page is to clicks/taps/keys. <strong>CLS</strong> = how much content shifts unexpectedly during load.</p>\n<h2>Diagnosis Flow</h2>\n<pre><code>1. Run PageSpeed Insights (pagespeed.web.dev)\n   → Identifies which metric(s) are failing\n\n2. Run Chrome DevTools Performance tab\n   → See the exact LCP element, INP interaction, or CLS source\n\n3. Fix the root cause (not symptoms)\n\n4. Re-measure — CWV changes take ~28 days to reflect in Search Console\n</code></pre>\n<h2>LCP Optimization</h2>\n<p>The LCP element is almost always: hero image, above-the-fold heading, or video poster.</p>\n<pre><code class=\"language-html\">&#x3C;!-- Priority: preload LCP image -->\n&#x3C;link rel=\"preload\" as=\"image\" href=\"/hero.webp\" fetchpriority=\"high\" />\n</code></pre>\n<pre><code class=\"language-tsx\">// Next.js Image component — LCP hero\n&#x3C;Image\n  src=\"/hero.webp\"\n  alt=\"Hero\"\n  width={1200}\n  height={600}\n  priority          // Removes lazy loading for LCP element\n  fetchPriority=\"high\"\n  sizes=\"(max-width: 768px) 100vw, 1200px\"\n/>\n</code></pre>\n<p><strong>LCP root causes and fixes:</strong></p>\n<p>| Root Cause | Fix |\n|------------|-----|\n| Large unoptimized image | Convert to WebP/AVIF, compress, use <code>&#x3C;Image></code> |\n| Image not preloaded | Add <code>priority</code> or <code>&#x3C;link rel=\"preload\"></code> |\n| Render-blocking resources | Defer non-critical JS, inline critical CSS |\n| Slow TTFB | CDN, edge caching, reduce server response time |\n| No resource hints | Add <code>dns-prefetch</code> and <code>preconnect</code> for 3rd-party origins |</p>\n<pre><code class=\"language-html\">&#x3C;!-- Preconnect to critical third-party origins -->\n&#x3C;link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\n&#x3C;link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin />\n</code></pre>\n<h2>INP Optimization</h2>\n<p>INP replaced FID in March 2024. It measures the worst-case interaction delay, not just the first.</p>\n<pre><code class=\"language-typescript\">// Break up long tasks with scheduler.yield()\nasync function processLargeList(items: Item[]) {\n  for (let i = 0; i &#x3C; items.length; i++) {\n    processItem(items[i]);\n    if (i % 50 === 0) {\n      await scheduler.yield();  // Yield to browser between chunks\n    }\n  }\n}\n\n// Defer non-critical work\nfunction handleClick() {\n  updateUI();              // Critical — do immediately\n  setTimeout(() => {       // Defer analytics/logging\n    logEvent(\"click\");\n  }, 0);\n}\n</code></pre>\n<p><strong>INP root causes and fixes:</strong></p>\n<p>| Root Cause | Fix |\n|------------|-----|\n| Long tasks (> 50ms) | Break with <code>scheduler.yield()</code> or <code>setTimeout</code> |\n| Hydration jank | Reduce JS bundle, use React <code>useTransition</code> |\n| Synchronous 3rd-party scripts | Load async/defer or move to Web Worker |\n| Heavy event handlers | Debounce, throttle, or move work off main thread |</p>\n<h2>CLS Optimization</h2>\n<p>CLS is caused by content shifting after initial render — images without dimensions, dynamically injected content, web fonts swapping.</p>\n<pre><code class=\"language-css\">/* Reserve space for images */\nimg, video {\n  aspect-ratio: attr(width) / attr(height);  /* Modern approach */\n}\n\n/* Font swap without layout shift */\n@font-face {\n  font-family: \"Inter\";\n  font-display: optional;  /* \"swap\" causes CLS; \"optional\" doesn't */\n}\n\n/* Reserve space for dynamic content */\n.ad-slot {\n  min-height: 250px;  /* Known ad size */\n}\n</code></pre>\n<pre><code class=\"language-tsx\">// Next.js Image — always specify width/height\n&#x3C;Image src=\"/photo.jpg\" alt=\"\" width={800} height={600} />\n\n// Skeleton placeholder — prevents shift\n{isLoading ? &#x3C;Skeleton className=\"h-48 w-full\" /> : &#x3C;Content />}\n</code></pre>\n<h2>Quick Wins Checklist</h2>\n<ul>\n<li>[ ] LCP element has <code>priority</code> or <code>fetchpriority=\"high\"</code></li>\n<li>[ ] All images have explicit <code>width</code> and <code>height</code></li>\n<li>[ ] Fonts use <code>font-display: optional</code> (or <code>swap</code> with explicit dimensions)</li>\n<li>[ ] No layout shifts from injected ads/embeds (reserved space)</li>\n<li>[ ] No long tasks > 50ms in click handlers</li>\n<li>[ ] Third-party scripts loaded with <code>async</code> or <code>defer</code></li>\n<li>[ ] Critical CSS inlined, non-critical deferred</li>\n<li>[ ] TTFB &#x3C; 600ms (CDN, caching, edge functions)</li>\n</ul>\n<h2>Measurement Tools</h2>\n<p>| Tool | Best For |\n|------|---------|\n| PageSpeed Insights | Lab + field data, actionable recommendations |\n| Chrome DevTools Performance | Deep LCP/INP diagnosis, flame charts |\n| Search Console Core Web Vitals | Real user data over 28-day window |\n| web-vitals npm package | Real user monitoring in production |</p>\n<pre><code class=\"language-typescript\">// src/lib/vitals.ts — Report to analytics\nimport { onLCP, onINP, onCLS } from \"web-vitals\";\n\nonLCP(metric => sendToAnalytics(\"LCP\", metric.value));\nonINP(metric => sendToAnalytics(\"INP\", metric.value));\nonCLS(metric => sendToAnalytics(\"CLS\", metric.value));\n</code></pre>\n"}