By Performance & SEOJuly 15, 2026·12 min read

Core Web Vitals, Page Speed & Technical SEO: The Complete Guide

What LCP, INP and CLS measure, what makes each one bad, and how to fix them, plus the technical SEO basics that decide whether a fast page gets indexed.

Interaction to Next Paint (INP) is the Core Web Vital that measures how quickly a page responds when someone clicks, taps or types: the time from the input until the browser paints the next frame. A good INP is 200 milliseconds or less. It sits next to LCP (loading) and CLS (visual stability), and each of the three scores traces back to a specific engineering choice. I once cut a marketing site's CSS by 45%, and the metric that moved most was LCP. This guide covers what each metric measures, the usual causes of a bad score and the fixes that worked for me, goes deepest on finding and fixing slow interactions, and ends with the technical SEO that decides whether Google sees the page at all.

What Core Web Vitals actually are

Core Web Vitals are three specific metrics Google uses to measure real-world user experience: how fast the main content appears, how responsive the page feels when someone interacts with it, and how visually stable it is. They're not arbitrary — each one maps to a moment where a slow or janky page visibly frustrates a real person.

Timeline: navigation starts, then TTFB when the server sends the first byte, FCP when the first content appears, LCP when the largest content renders, then the page becomes interactive and INP is measured on clicks and taps. CLS is tracked for the whole visit.

Where each metric happens during a page load

LCP — Largest Contentful Paint

LCP measures how long the largest visible element takes to fully render on screen. That's usually a hero image, a big block of text, or a banner. It's your proxy for "does this page feel loaded yet."

Good: 2.5 seconds or less. Needs improvement: 2.5 to 4 seconds. Poor: over 4 seconds.

Common causes: a slow server response delaying everything downstream, render-blocking CSS or JavaScript sitting between the browser and the content, unoptimized images that take too long to download, and client-side rendering that makes the browser wait on JavaScript before it can even start painting content.

How to fix it: compress and serve images in modern formats like WebP or AVIF, preload the LCP image or font instead of letting the browser discover it late, remove or defer unused CSS and JavaScript, and move to server-side rendering or static generation so the browser isn't waiting on a JS bundle to show anything. When I cut CSS bundle size by 45% on a marketing site, LCP was the metric that moved the most. Less CSS to parse before first paint means the browser gets to the real content faster.

index.html
<!-- If the LCP image is an <img> in the HTML, the browser finds it
     early on its own. fetchpriority="high" tells it to fetch it first. -->
<img
  src="/hero.webp"
  alt="Product hero shot"
  width="1200"
  height="600"
  fetchpriority="high"
/>

<!-- Preload helps when the LCP image is discovered late,
     for example a CSS background or an image added by JavaScript -->
<link rel="preload" as="image" href="/hero-bg.webp" fetchpriority="high" />

INP — Interaction to Next Paint

INP replaced an older metric called FID (First Input Delay) as an official Core Web Vital in March 2024. FID only measured the delay before the browser started processing your first click or tap. INP measures responsiveness across every interaction during the visit (clicks, taps and key presses) and reports the worst, or near-worst, one.

Good: 200 milliseconds or less. Needs improvement: 200 to 500 milliseconds. Poor: over 500 milliseconds.

Every interaction's latency has three parts, and every INP fix targets one of them:

  • –Input delay: the time from the click, tap or key press until your event handlers start running. It's long when the main thread is busy with something else, like a script still evaluating or a timer callback.
  • –Processing duration: the time your event handlers take to run.
  • –Presentation delay: the time from the handlers finishing until the next frame is on screen. That covers style recalculation, layout and paint, plus requestAnimationFrame and ResizeObserver callbacks.

INP counts clicks, taps and key presses. Hovering and scrolling don't count. For most pages INP is the single slowest interaction of the visit, but on pages with lots of interactions one highest value is ignored for every 50 interactions, so a single random hiccup doesn't define the page. Google then takes the 75th percentile across page views, split into mobile and desktop. If a visitor never interacts, that visit has no INP at all (details in web.dev's INP definition).

Browser support changed recently. The part of the Event Timing API that INP depends on (interactionId) now ships in Chrome and Edge 96+, Firefox 144+ and Safari 26.2+, and the web-vitals library reports INP in all three. CrUX, the dataset behind Google Search and Search Console, still only comes from Chrome users, so Search Console shows Chrome's view of your INP.

Common causes: heavy JavaScript execution blocking the main thread when a user clicks something, large event handlers doing too much synchronous work, and third-party scripts (analytics, chat widgets, ad tags) competing for the main thread at the worst possible moment.

How to find slow interactions

Start with field data, because that's what counts. Search Console's Core Web Vitals report and PageSpeed Insights tell you whether INP is a problem and on which group of URLs, but not which button is slow. For that you need attribution from real visitors. The web-vitals library's attribution build gives you the element, the interaction type and the time spent in each of the three parts:

lib/debugINP.ts
import { onINP } from 'web-vitals/attribution'

onINP(({ value, rating, attribution }) => {
  const {
    interactionTarget, // CSS selector for the element the user clicked, tapped or typed into
    interactionType, // 'pointer' or 'keyboard'
    inputDelay, // waiting for the main thread to be free
    processingDuration, // your event handlers running
    presentationDelay, // style, layout and paint for the next frame
    longestScript, // the script that ran longest during the interaction, if known
  } = attribution

  console.table({
    value,
    rating,
    interactionTarget,
    interactionType,
    inputDelay,
    processingDuration,
    presentationDelay,
    slowestScript: longestScript?.entry.sourceURL,
    slowestScriptPhase: longestScript?.subpart,
  })
})

In production, send these fields to your analytics endpoint (the sendToAnalytics pattern in the field data section below works) instead of logging them. A high inputDelay points at other work on the main thread, a high processingDuration at your handler, and a high presentationDelay at rendering cost. longestScript names the script that ran longest during the interaction, which is often a third-party tag.

Then reproduce it locally. The Performance panel in Chrome DevTools shows your local INP live as you click around, lists each interaction with its phases broken down, and can pull in CrUX field data for comparison. Turn on CPU throttling to get closer to a mid-range phone, record a trace while you repeat the slow interaction, and look for long tasks (marked with a red corner) inside it.

How to fix it: break up long JavaScript tasks into smaller chunks so the browser can breathe between them, debounce expensive operations like search-as-you-type, move non-urgent work off the main thread with web workers, and audit third-party scripts — load them lazily or remove the ones that aren't earning their cost.

How to improve INP, phase by phase

Break up long tasks (input delay and processing). Any task over 50 milliseconds is a long task, and while it runs the browser can't respond to input. The fix is to yield: pause your own work so the browser can handle input and paint, then carry on. scheduler.yield() does this and puts your continuation ahead of other queued tasks. It works in Chrome and Edge 129+ and Firefox 142+, but not in Safari, so you need a fallback:

lib/yieldToMain.js
// Yield to the main thread: scheduler.yield() where it exists,
// a setTimeout fallback everywhere else (Safari has no scheduler.yield()).
export function yieldToMain() {
  if (globalThis.scheduler?.yield) {
    return globalThis.scheduler.yield()
  }
  return new Promise((resolve) => setTimeout(resolve, 0))
}

// Run a long list of work without creating one giant task.
// Yields roughly every 50ms so a click never waits long.
export async function processInChunks(items, processItem) {
  let lastYield = performance.now()
  for (const item of items) {
    processItem(item)
    if (performance.now() - lastYield > 50) {
      await yieldToMain()
      lastYield = performance.now()
    }
  }
}

Do the visible part first (processing). Inside a click handler, update the UI the user is waiting for, yield, then do the rest. The browser gets a chance to paint before the analytics call and the storage write run:

Yield after the visible update
import { yieldToMain } from './lib/yieldToMain.js'

// updateCartBadge, sendAnalytics and saveCartToStorage are your own functions
const button = document.querySelector('#add-to-cart')

button.addEventListener('click', async () => {
  updateCartBadge() // what the user is waiting to see
  await yieldToMain() // let the browser paint it
  sendAnalytics() // everything else can wait a moment
  saveCartToStorage()
})

Debounce input that fires on every keystroke (processing). Search-as-you-type shouldn't filter a big list on every key press. Wait until the user pauses, and if the search itself is heavy, move it into a Web Worker:

Debounce search-as-you-type
function debounce(fn, waitMs) {
  let timer
  return (...args) => {
    clearTimeout(timer)
    timer = setTimeout(() => fn(...args), waitMs)
  }
}

// runSearch is your own search function
const searchInput = document.querySelector('#search')

searchInput.addEventListener(
  'input',
  debounce((event) => runSearch(event.target.value), 250)
)

Shrink the rendering work (presentation delay). The bigger the DOM, the longer style and layout take after every update. Count elements with document.querySelectorAll('*').length in the console, render long lists with virtualization or pagination, and put content-visibility: auto on long offscreen sections so the browser skips rendering them until they're near the viewport (it works in Chrome 85+, Firefox 125+ and Safari 18+). Also avoid layout thrashing: reading a layout value like offsetHeight right after changing styles forces the browser to run layout synchronously, inside your handler.

Move heavy work off the main thread. Parsing large JSON, filtering thousands of rows or processing images can run in a Web Worker, where it can't block input at all. And keep checking third-party scripts: their long tasks count against your INP even though it isn't your code.

CLS — Cumulative Layout Shift

CLS measures visual stability — how much content unexpectedly jumps around while someone is on the page, from loading through scrolling and late interactions. It scores the worst burst of shifts (shifts less than 1 second apart, up to 5 seconds long). You've felt this: you go to tap a button and an ad loads above it, shifting everything down, and you tap the wrong thing. CLS is Google's way of scoring exactly how much of that happened.

Good: 0.1 or less. Needs improvement: 0.1 to 0.25. Poor: over 0.25.

Common causes: images or ads without reserved dimensions, web fonts swapping in and reflowing text (FOUT, when the fallback font is replaced by the web font), and content like cookie banners or promo bars being injected above existing content after the first render.

How to fix it: always set explicit width and height (or aspect-ratio) on images and embeds so the browser reserves space before the asset loads, use font-display: optional or swap carefully with matched fallback fonts, and reserve space for dynamically injected content like ads or banners instead of letting it push everything else down.

components/ProductHero.tsx
import Image from 'next/image'

// Setting width and height lets Next.js reserve the exact space
// before the image downloads, so nothing shifts when it loads.
export function ProductHero() {
  return (
    <Image
      src="/hero.webp"
      alt="Product hero shot"
      width={1200}
      height={600}
      loading="eager" // next/image lazy-loads by default; never lazy-load the LCP image
      fetchPriority="high" // this is likely the LCP element, so fetch it first
    />
  )
}

The supporting metrics you'll also see

These aren't official Core Web Vitals, but they show up in Lighthouse and PageSpeed Insights and directly influence the three above, so they're worth knowing.

  • –TTFB (Time to First Byte): how long the server takes to send the first byte of the response. Good is under 800ms. Slow TTFB delays every metric after it. If the server side is a black box to you, what a server does with a request is a short primer, and caching responses in Redis is a common fix.
  • –FCP (First Contentful Paint): when the first piece of content (text, image, or canvas) appears on screen. Good is under 1.8 seconds.
  • –TBT (Total Blocking Time): a lab metric estimating how long the main thread was blocked and unable to respond to input during load. Good is under 200ms; it's essentially a lab-measurable preview of what causes poor INP.
  • –Speed Index: how quickly content is visually populated during load, measured across the whole loading sequence rather than a single point in time.

Lab data vs. field data — and why your scores don't match

A lot of confusion comes from mixing up two different measurement sources. Lab data comes from a simulated run: tools like Lighthouse load your page once, on a fixed device and network profile, and report what happened. Field data (also called Real User Monitoring, or the Chrome UX Report / CrUX) comes from real visitors on their own devices and networks, aggregated over the past 28 days. Google's ranking signal uses field data, not lab data. So a perfect Lighthouse score doesn't guarantee good real-world Core Web Vitals if your users are on slow phones or weak connections.

Real visits are measured by Chrome and collected in the Chrome UX Report over a rolling 28 days, which PageSpeed Insights and Search Console show as field data and Google Search uses. A Lighthouse lab run gives a single simulated result that helps debugging but is not a ranking input.

Field data feeds Google Search; lab data is for debugging

Use Lighthouse or PageSpeed Insights to diagnose and debug specific issues on a page. Use Search Console's Core Web Vitals report or CrUX data to know what Google actually sees from your real traffic.

lib/reportWebVitals.ts
import { onLCP, onINP, onCLS, type Metric } from 'web-vitals'

// Send each metric to your analytics endpoint as it's measured in a
// real visitor's browser. This is field data -- the same kind of data
// Google's CrUX report is built from -- not a simulated lab score.
function sendToAnalytics(metric: Metric) {
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    delta: metric.delta,
    id: metric.id,
  })
  // sendBeacon returns false if the browser refuses to queue it
  if (!navigator.sendBeacon('/api/vitals', body)) {
    fetch('/api/vitals', { method: 'POST', body, keepalive: true })
  }
}

onLCP(sendToAnalytics)
onINP(sendToAnalytics)
onCLS(sendToAnalytics)

Technical SEO: the foundation that makes speed matter

None of the above matters if search engines can't crawl, understand, and index your page in the first place. Technical SEO is the plumbing that makes sure your content is even eligible to rank before performance becomes a tiebreaker.

Crawlability and indexability

Search engines find pages by following links and reading your robots.txt and sitemap.xml. robots.txt tells crawlers which paths they're allowed to visit; sitemap.xml lists the pages you want indexed. A page can be perfectly fast and still invisible to Google if it's blocked by robots.txt, marked noindex by mistake, or simply has no internal links pointing to it. One trap: robots.txt stops crawling, not indexing. A blocked URL can still show up in results, without its content, if other pages link to it, and Google can't see a noindex tag on a page it isn't allowed to crawl. To keep a page out of Google, use noindex and let it be crawled. When I built programmatic SEO pipelines generating hundreds of CMS pages automatically, the sitemap and internal linking structure mattered as much as the content itself — pages that existed but weren't linked from anywhere effectively didn't exist to Google.

robots.txt
# robots.txt -- controls what crawlers are allowed to visit
User-agent: *
Disallow: /admin/
Disallow: /cart/
Allow: /

Sitemap: https://example.com/sitemap.xml

Canonical tags and duplicate content

A canonical tag tells search engines which version of a page is the "real" one when multiple URLs show similar or identical content — for example, a product page reachable through both /shirt and /category/shirt. Without it, search engines may split ranking signals across duplicates or pick the wrong version to show in results.

Structured data

Structured data (usually written as JSON-LD) is a machine-readable description of your page's content: this is a recipe, this is an article by this author published on this date, this is a product priced at this amount. It doesn't directly boost rankings, but it makes you eligible for rich results (article bylines and dates, product prices, breadcrumbs), which can improve click-through rate.

JSON-LD in the page <head>
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Article",
  "headline": "Core Web Vitals, Page Speed & Technical SEO: The Complete Guide",
  "author": {
    "@type": "Person",
    "name": "Prashant Dasari"
  },
  "datePublished": "2026-07-15",
  "dateModified": "2026-07-15",
  "image": "https://example.com/og-image.jpg"
}
</script>

Mobile-first indexing and HTTPS

Google indexes and ranks mainly using the mobile version of your page, not desktop. If your mobile layout hides content or breaks something that works on desktop, that's what gets evaluated. HTTPS, meanwhile, is table stakes: it's a minor ranking signal and a hard requirement for browser features like service workers and geolocation.

Every redirect hop adds latency, and Googlebot stops following after 10 hops. (Google has said since 2016 that redirects don't lose PageRank, so this is about speed and crawling, not link equity.) Chains of redirects (A to B to C) should be flattened to a single hop. 404s that used to be real pages should either be restored or 301-redirected to the closest relevant page, so you're not sending users or crawlers into dead ends.

Where speed and technical SEO meet

There's no single "page experience" signal. Google says its ranking systems use Core Web Vitals, along with other things that line up with a good experience, like HTTPS and avoiding intrusive interstitials. It's not usually the biggest ranking factor, since content relevance still dominates. But among pages that are already relevant and well built, page experience can be the tiebreaker. More importantly, slow, janky pages get abandoned before a search engine's opinion even matters, and those lost visitors cost you conversions whatever you rank.

A practical checklist to start with

  • –Run PageSpeed Insights on your key pages and note field data (not just the lab score).
  • –Compress and lazy-load images below the fold; give the LCP image fetchpriority="high" and never lazy-load it.
  • –Set explicit dimensions on every image, embed, and ad slot to eliminate layout shift.
  • –Audit third-party scripts and remove or lazy-load anything not essential to first interaction.
  • –Check robots.txt and sitemap.xml aren't accidentally blocking pages you want indexed.
  • –Add canonical tags on any pages reachable through multiple URLs.
  • –Flatten redirect chains and fix orphaned 404s.

Common questions

What is a good INP score?

200 milliseconds or less at the 75th percentile of page views, measured separately for mobile and desktop. 200 to 500 milliseconds needs improvement, and anything over 500 is poor.

Why doesn't Lighthouse show my INP?

A standard Lighthouse run loads the page without clicking anything, so there's no interaction to measure. It reports Total Blocking Time as a stand-in. Lighthouse's timespan mode and the DevTools Performance panel both measure INP while you interact with the page.

Does INP affect Google rankings?

Google says its ranking systems use Core Web Vitals, INP included, measured from Chrome field data. Relevance matters far more, so treat a good INP as a tiebreaker and a conversion win rather than a ranking trick.

What replaced First Input Delay?

INP replaced FID as a Core Web Vital in March 2024. FID only measured the input delay of the first interaction. INP measures all three parts of every interaction during the visit.

Does scheduler.yield() work in Safari?

Not as of October 2026. Feature-detect it and fall back to a setTimeout-based yield, like the yieldToMain helper above, or add the scheduler-polyfill package.

If your scores are in the red

Start with the worst metric on your most important page, fix that, and measure again. Chasing every score at once is how weeks disappear. I fix slow Next.js and Webflow sites. If your LCP is in the red, tell me about it, or see my work first.