# Core Web Vitals: LCP, CLS and INP explained with a real case

> What LCP, CLS and INP actually measure, why your local Lighthouse often lies, and the real fixes applied on this blog to get past it.

- Autore / Author: Diego Betto (https://diegobetto.com/about/)
- Pubblicato / Published: 2026-08-28
- URL: https://diegobetto.com/en/core-web-vitals-lcp-cls-inp/
- Tag: performance, core web vitals, lighthouse, seo

A few weeks ago I ran Lighthouse on this very blog after some changes, expecting a clean 100 in Performance. I got 95. Total Blocking Time at 182ms, a report full of "unused" JavaScript I'd never written myself, and a console error caused — literally — by a browser extension that analyzes search engines. None of those three things had anything to do with the site's code.

This article covers what I dug into: what LCP, CLS and INP actually measure, when the number you're seeing is noise rather than a real problem, and the three genuine fixes I applied here — with numbers, not just a claim.

## The three metrics, without the jargon

**LCP (Largest Contentful Paint)** measures how long it takes for the largest visible element in the initial viewport to appear — almost always a hero image or a big block of text. Under 2.5 seconds is "good".

**CLS (Cumulative Layout Shift)** measures how much the page "jumps" while loading — an image without `width`/`height` that pushes the text below it down is the textbook example. Under 0.1 is "good".

**INP (Interaction to Next Paint)** measures the time between a user's click/tap and the moment the browser visually updates the screen in response. Under 200ms is "good".

> INP replaced FID in March 2024: if you've read older guides, they probably talk about FID (First
> Input Delay) as the third metric. Google officially replaced it with INP because FID only measured
> the delay before the interaction started being processed — INP measures the whole experience, all
> the way to the rendered frame. A site can have great FID and terrible INP if the interaction
> starts right away but the work that follows blocks the main thread for a long time.

## The problem (almost) no guide tells you: your local Lighthouse lies

Here's what I found digging into the raw JSON of that 95 report: dozens of `chrome-extension://` entries in the unused-JavaScript audits. A password manager. React DevTools. An ad blocker with its EasyList/EasyPrivacy filters loaded. All running **during** the measurement, all counted in Total Blocking Time as if they were the site's own code.

The console error that failed the Best Practices audit? `net::ERR_BLOCKED_BY_CLIENT` on a Cloudflare analytics script — blocked by my own ad blocker, not a site bug.

> Don't trust a Lighthouse audit run from a regular Chrome profile: if you have extensions installed
> — and anyone who develops always has at least five — what Lighthouse measures includes their code
> running on the page. For a reliable number: use an incognito window with extensions disabled, or{" "}
> [> PageSpeed Insights >](https://pagespeed.web.dev/)
> , which runs in a controlled environment away from your browser.

There's also a deeper distinction to keep in mind, beyond extensions: **lab data isn't field data**. Lighthouse (lab data) runs a single page loaded once, under simulated conditions. The real Core Web Vitals Google uses for ranking comes from **CrUX** (Chrome UX Report) — aggregated data from real users, over a rolling 28-day window, visible in Search Console. A site can have a perfect local Lighthouse and a mediocre field INP if, say, most of its real traffic comes from low-end smartphones that your dev laptop doesn't simulate.

## Three real fixes, applied here

Once the noise was stripped out, three genuinely mine problems remained. I'm listing them with before/after because theory without numbers convinces no one.

### 1. A CSS file blocking render

The site's global stylesheet (~10KB compressed, shared by every page) was a render-blocking request: the browser had to download and parse it before the first paint. With Astro, the fix was one config line:

```javascript
// astro.config.mjs
export default defineConfig({
  build: {
    inlineStylesheets: "always",
  },
});
```

With `inlineStylesheets: 'always'`, the CSS ends up directly inside the `<head>` of every page instead of an external file to request. The trade-off is real — you lose the shared cache of the file across pages — but for a blog where most visitors arrive from a search on a single article (not browsing ten pages in the same session), removing that blocking request matters more than the cache.

### 2. The LCP image loaded as if it didn't count

The homepage's first three cards — the ones always visible without scrolling — used `loading="lazy"` on the cover, the same directive used for every image further down the page. For the element that _is_ the LCP, lazy loading is counterproductive: it tells the browser to wait before it even starts discovering that resource.

```astro
<Image
  src={cover}
  alt={coverAlt}
  loading={isAboveTheFold ? 'eager' : 'lazy'}
  fetchpriority={isFirstCard ? 'high' : undefined}
  width={400}
  height={400}
/>
```

`loading="eager"` on the above-the-fold cards, and `fetchpriority="high"` only on the very first one — the one statistically most likely to be the actual LCP element. No need to max-priority everything: just that one.

### 3. Images heavier than they needed to be

The same cards served a fixed 400×400px raster regardless of how big they actually were on screen — on certain grid-layout breakpoints, they were shown at a real 250×250px. Bytes downloaded for pixels never displayed.

```astro
<Image
  src={cover}
  widths={[260, 400, 600]}
  sizes="(min-width: 768px) 260px, (min-width: 640px) 45vw, calc(100vw - 2rem)"
  ...
/>
```

With `widths` + `sizes`, Astro generates a real `srcset` and the browser picks the right variant based on the layout's actual width, not the largest one available regardless.

> Chasing the exact 100 is almost always wasted time: after these three fixes, what was left were
> fractions of a point — FCP and LCP already under a second, a hair away from the max score because
> of Lighthouse's scoring curve, not because of a real problem to solve. At some point it's worth
> stopping and looking at the real field data (Search Console → Core Web Vitals) instead of
> endlessly optimizing a lab number your users will never see.

## What to take away

If you're looking at a low Lighthouse report and don't know where to start: first separate the noise (extensions, third-party scripts blocked by your own browser) from the signal, then look specifically at the LCP element (is it loading lazy? does it have `fetchpriority`?) and the above-the-fold images (are they sized for the real layout or just for the worst case?). Those are the two spots where, empirically, most of the fixable-in-an-afternoon problems hide.

If your field INP stays high even after fixing LCP and CLS, the next suspect is JavaScript blocking the main thread on frequent interactions — I've written separately about how [debounce and throttle](https://diegobetto.com/debounce-throttle-javascript/) can stop a single event (scroll, resize, typing) from triggering repeated, unnecessary work. And if you use Astro, it's also worth checking small things like [trailing slash handling](https://diegobetto.com/astro-trailing-slash/), which look like routing details at first glance but can generate avoidable redirects — another couple of milliseconds lost before the page even really starts loading.
