Core Web Vitals — LCP, INP, CLS in Plain English
You open PageSpeed Insights, punch in your domain, wait 15 seconds. And you get four orange circles and one red one. Underneath them — the letters LCP, INP, CLS, FCP, TTFB. Below that, a list of thirty "opportunities" in English: eliminate render-blocking resources, reduce unused JavaScript, properly size images. You scroll. Nothing makes sense. You close the tab.
I've been through this. And I've been through it with dozens of clients. So let's figure out what's actually important and what's just noise.
Out of the entire PSI report, only three letters matter: LCP, INP, CLS. These are Core Web Vitals. Google publicly confirmed they've been part of ranking signals since June 2021. And it has only increased their weight since. Out of the general 30+ SEO factors, Core Web Vitals are the only thing Google officially measures through real users' browsers.
The metrics have evolved. In March 2024, INP replaced FID (First Input Delay). FID only counted the delay of the first click. INP looks at every interaction during a visit and takes the worst ones. Tougher, more realistic, and harder to game now.
The goal of this article is to explain the three metrics in human language, give you concrete targets, and show what to do in code. Without the fluff about "speed up your site."
LCP — Largest Contentful Paint
This is the time from when the page starts loading to the moment the user sees the largest element in the visible part of the screen. Usually it's a hero image, a video, or a big block with an h1. The browser decides on its own what's largest.
Targets:
- < 2.5s — good
- 2.5–4s — needs work
-
4s — bad
Those 2.5 seconds aren't from your click. They're from the moment the browser starts receiving the first byte. That means DNS, TCP, TLS, server response, downloading HTML, parsing, downloading the image, rendering — all of it has to fit into 2500 milliseconds.
What usually breaks LCP:
A heavy hero image. The designer sent over a JPEG at 4000×3000 weighing 2.8 MB. You wired it in without any optimization. The browser downloads 2.8 MB over 4G, and LCP shoots past 5 seconds. Fix it with next/image and priority, WebP/AVIF, and proper sizes. On Next 15 — <Image src={hero} priority placeholder="blur" /> and you're done.
Render-blocking JS and CSS. You've got synchronous <script> tags or a huge CSS file in <head>. The browser can't render the DOM until it downloads and executes them. Solution: defer/async on scripts, inline the critical CSS, and load the rest as a separate file with the media="print" trick or via critical CSS.
Slow TTFB. Time To First Byte. The server thinks for 800 ms before sending HTML. Causes: server rendering without caching, a heavy database, cold-start serverless functions, geographically distant hosting. Solution: a CDN in front of everything (Cloudflare, Vercel Edge), page-level caching, ISR for static parts.
No preload for critical resources. The font used in the h1 doesn't start loading until CSS is parsed. In between — blank space. Add <link rel="preload" as="font" href="..." crossorigin> for your main font, and LCP drops by 300–500 ms.
What not to do: lazy-load the hero image. This is the classic mistake. You've got <img loading="lazy"> on the LCP image itself, and it sits there waiting for the scroll observer to activate it. LCP goes through the roof. For the hero — loading="eager" and fetchpriority="high".
Another subtlety: the LCP element can change as the page loads. First it's the h1, then the hero renders — now it's the hero. Then the video poster loads — now it's that. The browser locks in the final LCP only when the page goes to background or when the first interaction happens. So if you've got an animation that slides the hero in after a second — LCP is measured from that second, not from the initial h1. The better anti-pattern is to let the critical visual be visible right away.
INP — Interaction to Next Paint
This is the time from when you tap a button to when the browser paints the next frame after responding to that tap. Not "when the event was handled" but "when the user saw the result."
Targets:
- < 200ms — good
- 200–500ms — needs work
-
500ms — bad
200 milliseconds is the threshold past which the human brain perceives the interface as "laggy." Below that — it feels instant.
INP counts all interactions during a session (clicks, taps, key presses) and takes the 98th percentile. So a single heavy click is enough to tank the metric.
What usually breaks INP:
Heavy JavaScript on the main thread. Clicking a filter in a catalog triggers a handler that recalculates 2000 products and re-renders the entire list. React does reconciliation for 600 ms. The user's finger already left the button — and the screen is still blank. Solutions: useTransition for heavy updates (React 18+), list virtualization, chunking work via scheduler.postTask.
Long tasks. Any main-thread task longer than 50 ms is a long task. If your click handler synchronously hits localStorage, parses JSON, and sends a fetch through an event tracker — all of that blocks the thread. Solution: move it to requestIdleCallback, offload analytics to a web worker.
Layout thrashing. A click handler reads offsetWidth, then changes a style, then reads offsetWidth again. The browser recalculates layout every time. On a complex page that adds 100+ ms out of thin air. Solution: batch DOM reads and writes, use requestAnimationFrame.
Bloated third-party scripts. Intercom, Hotjar, Google Tag Manager. They hang their handlers on top of yours. Every click goes through a chain of interceptors. Solution: load analytics lazily after load, use Partytown to move scripts into a worker.
Code-splitting helps too, but not directly — it doesn't lower INP, it lowers the amount of JS that needs parsing. Indirectly, that frees up the main thread.
From experience: INP usually tanks on two kinds of screens — list pages with filters and forms with autocomplete. For filters, input debouncing helps (300 ms is enough) plus useDeferredValue for rendering results. For autocomplete — cancel the previous request on each new keystroke (AbortController) and virtualize the dropdown if there are more than thirty results.
CLS — Cumulative Layout Shift
This is the sum of all layout shifts during the page's lifetime. Not time, not milliseconds — a dimensionless number from 0 and up. It's calculated as impact × distance: how much screen area shifted, by what percentage of the screen it moved.
Targets:
- < 0.1 — good
- 0.1–0.25 — needs work
-
0.25 — bad
0.1 in practice looks like, for example, an image taking up 30% of the screen that jumps 30% of the viewport height. One such jump and you're on the edge.
CLS is about "I'm tapping a button, and at that very moment a banner appears above it, and I tap the banner instead." It drives everyone crazy. Google knows this.
What usually breaks CLS:
Images and iframes without explicit dimensions. If an <img> has no width and height attributes (or CSS equivalents), the browser doesn't know how much space to reserve. The image loads in — and pushes all the content below it down. Solution: always set width and height (or aspect-ratio in CSS). In Next/Image this is on by default if you pass dimensions.
Dynamic injections. A cookie banner that appears at the top one second after load and shifts the whole page down. AdSense slotting a block into the middle of an article. Any A/B test that mutates the DOM on the fly. Solution: reserve the space ahead of time. The cookie banner should be a fixed overlay, not a block element. Ads should sit in slots of fixed height.
Font swap without size-adjust. A custom font loads, with a fallback in the meantime. They have different line heights. When the custom font arrives, the text reflows, and everything jumps. Solution: font-display: swap plus size-adjust in @font-face so the fallback occupies exactly the same space. Or font-display: optional, which won't swap on slow connections. In Next, use next/font — it calculates size-adjust for you.
Async content above the fold. A recommendations list is fetched and inserted above the hero. The hero gets pushed down. Never insert content above what the user is already seeing. If you need to — append it below, not above.
Field vs Lab data
This is the part where half of people get lost.
PageSpeed Insights shows two blocks. On top — "Discover what your real users are experiencing" (field data). Below — "Diagnose performance issues" (lab data). These are different things.
Lab data is a single Lighthouse run in ideal conditions. It emulates a Moto G4 on slow 4G, no extensions, no cache, no other tabs. Reproducible, convenient for debugging, but not reflective of reality.
Field data is CrUX. Chrome User Experience Report. Google collects anonymous metrics from real Chrome users (those who have sync and history enabled). It aggregates over the last 28 days. If enough people have visited your site, Google shows the 75th percentile of their metrics.
Google ranks by field. Lab is just a hint. You can have 100/100 in Lighthouse and still have a red CrUX, because real users come in on slow 2019-era Android flagships through subway tunnels.
Where to view field data:
- PageSpeed Insights — the simplest way, for the home page and individual URLs.
- Search Console → Core Web Vitals report — gives you a breakdown by URL groups: "search," "product cards," "articles." You can see exactly where things are sagging.
- PSI API — for automation. Run a script once a week against a list of important pages, drop the results into a table, build a trend chart.
- CrUX BigQuery dataset — for those who know SQL. You can pull full distributions, not just percentiles.
One important note. If a site has low traffic, CrUX won't show field data. It'll just say "not enough data." In that case, Google falls back to origin-level data (across the whole domain) for ranking, or to lab data. So even on a young site, it's worth squeezing out the best Lighthouse score you can — that's your insurance until CrUX builds up.
What DOESN'T help
A list of things people often recommend that don't actually move Core Web Vitals — or move them in the wrong direction.
Minifying CSS on an already lightweight site. You've got 18 KB of CSS, gzip squeezes that down to 4 KB. PSI tells you "minify CSS, save 1.2 KB." Those 1.2 KB won't do a thing. You're wasting your time.
Lazy-loading everything. loading="lazy" on every image — the most common mistake after Chrome added native lazy loading. Including the hero. Including the logo in the header. The result: images above the fold wait on the intersection observer instead of loading right away. LCP goes up. Lazy is only for things below the fold.
Aggressive preloading of everything that moves. You read an article about "preload critical resources" and slapped <link rel="preload"> on ten files. The browser fetches them at a priority higher than HTML. The network is congested. The LCP image waits its turn. Preload is a sharp tool. Two or three critical resources, max.
A Service Worker with "cache everything." Caching is clever, but if you don't understand what you're caching, the user sees a stale version of the site. This has nothing to do with Core Web Vitals at all, and it just adds problems.
Bottom line
If your time is limited, here's the priority order.
LCP first. A slow page means users bail before they've seen anything. That hits both rankings and conversions directly. Hero image via next/image priority, font preload, CDN in front of everything. That gets you 80% of the result.
CLS second. Jumpy layout is irritating and kills trust. Especially on mobile. Dimensions on media, font-display with size-adjust, don't insert content above what's already visible. These fixes are quick and they don't come back.
INP third. It's the trickiest of the three and requires the most work — profiling, tracking down heavy handlers, refactoring. But until LCP and CLS are in order, it's too early to take on INP.
Watch the metrics in field data — Search Console and PSI. Use lab only as a sandbox for checking fixes before you deploy. And remember: a single run isn't enough. CrUX updates once a day and aggregates over 28 days. After making fixes, wait at least two weeks before drawing conclusions.