Short answer. Core Web Vitals are three real-user metrics: LCP (largest element paint, good is up to 2.5 s), INP (responsiveness to taps, up to 200 ms) and CLS (visual stability, up to 0.1). Scores come from the 75th percentile of real visits over a rolling window, not one Lighthouse run. Google revises the thresholds periodically.
What Core Web Vitals are and why they matter
Core Web Vitals (CWV) are a subset of Google's Web Vitals initiative. Out of dozens of possible performance indicators, three were picked because they sit closest to the subjective feeling that a site is fast: did the content show up, does the page answer taps, does the layout stay put under your finger.
The key difference from familiar metrics such as TTFB, DOMContentLoaded or "page weight" is where the measurement happens. TTFB describes infrastructure; CWV describe perception. A page can weigh 4 MB and feel fast if the first viewport paints in a second and everything else arrives later. The reverse is also true: a lightweight page with a render-blocking font and an ad injected above the text feels broken at any weight.
The second difference is that the metrics are collected in the visitor's browser, not on a test rig. A page's score is taken at the 75th percentile of the distribution: "good" means at least three quarters of visits had a good experience. One slow visit on a train does not move the number, but a systematic stall on mid-range Android devices does — even when everything is green on your laptop.
Third, the metrics are reported separately for mobile and desktop visits. The classic pattern is a green desktop and a red mobile, because a phone CPU is several times slower and every kilobyte of JavaScript takes proportionally longer to execute.
Good Core Web Vitals are not an end in themselves for SEO. They are a proxy for user satisfaction: a fast, responsive, stable interface retains and converts better. Ranking is a side effect, not the prize.
The three metrics and their thresholds
Each metric has three bands: good, needs improvement, poor. A page passes the assessment only when all three land in the good band at the 75th percentile.
| Metric | What it measures | Good | Needs improvement | Poor |
|---|---|---|---|---|
| LCP Largest Contentful Paint | Time to paint the largest visible element in the initial viewport | ≤ 2.5 s | 2.5–4.0 s | > 4.0 s |
| INP Interaction to Next Paint | Delay between a user action and the next painted frame | ≤ 200 ms | 200–500 ms | > 500 ms |
| CLS Cumulative Layout Shift | Total unexpected layout movement (a unitless score) | ≤ 0.1 | 0.1–0.25 | > 0.25 |
It also helps to know what each metric actually reacts to — that usually tells you who on the team owns it.
| What you change | LCP | INP | CLS |
|---|---|---|---|
| Server response time, caching, CDN | Strong | Weak | None |
| Weight and format of above-the-fold images | Strong | None | Indirect |
| JavaScript volume and code splitting | Medium | Strong | Indirect |
width/height attributes, reserved space | None | None | Strong |
| Font loading strategy | Medium | None | Strong |
| Third-party scripts, widgets, ads | Medium | Strong | Strong |
Google revises both the thresholds and the metric set periodically: FID has already been retired in favour of INP and is no longer part of Core Web Vitals. Do not hard-code these numbers into reports and dashboards forever — re-check the official documentation every few months.

LCP: speeding up the main content
Largest Contentful Paint records the moment the largest content element in the initial viewport is painted. Usually that is a hero image, an article cover, a large heading or a block of text. The element can change during load: the heading becomes the LCP candidate first, then the image takes over.
What LCP is made of
Break the metric into four sequential phases and it becomes obvious where the time actually goes:
- TTFB — time to the first byte of the response, covering DNS, TCP, TLS and backend work.
- Resource load delay — how long the browser waited before it even learned about the LCP image. If the image is injected by a script or set as a CSS background, discovery is postponed.
- Resource load duration — the actual download.
- Element render delay — the gap between the resource being ready and pixels appearing; render-blocking CSS and an unfinished font load get in the way here.
In practice the biggest losses sit in the first two phases, while teams tend to optimise the third one — compressing an already small image.
Find the real LCP element
Do not guess. In Chrome DevTools open the Performance panel, record a load and find the LCP marker on the timeline — DevTools highlights the exact DOM node. PageSpeed Insights shows the same element in its diagnostics. More often than not the LCP turns out to be an unremarkable block of text or a header logo rather than the hero banner.
Remove the discovery delay
The cheapest win is telling the browser about the main resource up front and setting priorities explicitly:
<!-- 1. Early connection to the host serving the image -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
<!-- 2. Preload the LCP image at high priority -->
<link rel="preload" as="image" href="https://cdn.example.com/hero.avif"
imagesrcset="hero-800.avif 800w, hero-1600.avif 1600w"
imagesizes="100vw" fetchpriority="high">
<!-- 3. The element itself: dimensions set, no lazy loading -->
<img src="https://cdn.example.com/hero.avif" width="1600" height="900"
alt="Description" fetchpriority="high" decoding="async">
<!-- 4. Above-the-fold font: preload plus swap -->
<link rel="preload" as="font" type="font/woff2"
href="/en/assets/fonts/inter-latin.woff2" crossorigin>
The width and height attributes pull double duty here: the browser reserves the space (that is CLS) and skips a layout recalculation once the file arrives (that indirectly helps LCP).
Cut TTFB down
Aim for under 800 ms — the lower it goes, the more budget the remaining phases get. What actually moves the needle:
- Cache the rendered HTML. Serve anonymous visitors from cache instead of rebuilding the page on every request.
- Use a CDN. It shortens the physical distance to the visitor and takes static assets off your origin. How that works is covered in what a CDN is and how it works.
- Enable HTTP/2 or HTTP/3. Multiplexing removes the request queue that HTTP/1.1 forces on you.
- Turn on compression. Brotli is noticeably more compact than gzip for text responses.
- Audit database queries. A single heavy unindexed query can eat the whole TTFB budget on its own.
TTFB and total response time are one command away:
curl -s -o /dev/null -w "dns: %{time_namelookup}s\ntls: %{time_appconnect}s\nttfb: %{time_starttransfer}s\ntotal: %{time_total}s\nsize: %{size_download} bytes\n" https://example.com/
A large gap between time_appconnect and time_starttransfer points at the backend. A large time_namelookup points at DNS. An inflated size_download usually means compression is off.
What not to do
- Never put
loading="lazy"on the LCP image. Lazy loading defers the request until the browser decides the element is needed, which guarantees a worse metric. Lazy loading is for what sits below the fold. - Do not preload everything. Ten
preloadhints in the head compete for the same connection: if everything is a priority, nothing is. - Do not hide the first viewport behind a fade-in. If the hero starts transparent and then "appears", LCP is recorded when it is actually painted — you have added the delay yourself.
A broader walk-through of slow page loads is in why a website loads slowly and how to fix it, and formats and compression are covered in image optimization for the web.

INP: why the interface stalls after a click
Interaction to Next Paint measures how long it takes from a user action to the next painted frame. Clicks, taps and key presses count; scrolling and hovering do not. Unlike the retired FID, which looked only at the first interaction and only at the delay before processing started, INP watches the whole session and reports effectively the worst interaction (on pages with many interactions a small share of outliers is discarded).
INP replaced FID in 2024. The practical consequence: you used to be able to get a green FID simply by deferring scripts. The new metric sees heavy handlers, slow re-renders and everything that happens long after the page has "loaded".
What the delay is made of
- Input delay — the main thread is busy with something else and the event waits in line.
- Processing time — your own handlers run: validation, requests, computation.
- Presentation delay — the browser recalculates styles and layout and paints the frame.
Each part needs separate treatment: trimming a handler is pointless if the problem is input delay caused by somebody else's analytics script.
What actually helps
- Yield to the main thread. A long task (over 50 ms) blocks input handling. Split it into chunks with explicit breathing room.
- Paint the response first, compute second. The user needs to see that the tap registered: show a spinner or change the button state before the heavy work, not after it.
- Move computation into a Web Worker. Parsing large JSON payloads, sorting thousands of rows and image processing do not belong on the main thread.
- Ship less JavaScript. Code splitting, tree shaking, dropping duplicate libraries. A kilobyte of JS costs more than a kilobyte of image: it still has to be parsed and executed.
- Keep the DOM to a sane size. A tree with tens of thousands of nodes makes every layout recalculation expensive; virtualise long lists.
- Avoid layout thrashing — interleaving geometry reads and style writes inside one loop.
A pattern for splitting a long task and handing control back to the browser:
// Yield to the main thread between chunks of work
function yieldToMain() {
if ('scheduler' in window && 'yield' in window.scheduler) {
return window.scheduler.yield();
}
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function processAll(items) {
let deadline = performance.now() + 45; // ~45 ms slices
for (const item of items) {
processOne(item);
if (performance.now() >= deadline) {
await yieldToMain(); // let the browser handle input
deadline = performance.now() + 45;
}
}
}
A detailed breakdown of the metric, its sub-parts and how to debug it lives in the dedicated article on INP in Core Web Vitals.
CLS: getting rid of layout jumps
Cumulative Layout Shift is the only unitless metric of the three. It scores unexpected movement of content that has already been painted — the case where a button slides down at the exact moment of the tap and the user hits a banner instead.
Every shift is scored as the product of two fractions: how much of the viewport was affected and how far things moved. The final value is not the sum across the whole page but the largest "session window": a burst of shifts that happen close together. The consequence matters — CLS accumulates over the entire lifetime of the page, not just during load. Lazy-loaded content, infinite scroll and slide-in panels all count.
Shifts caused by a user action are excluded for roughly half a second after the interaction, so expanding an accordion on click does not hurt the metric.
What to fix
/* 1. Reserve space for media with an aspect ratio, not a fixed height */
.hero img {
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
}
/* 2. Ad and embed slots: a minimum height before content arrives */
.ad-slot {
min-height: 280px;
contain: layout;
}
/* 3. Fonts: metric-matched fallback so the swap does not move text */
@font-face {
font-family: 'Inter';
src: url('/assets/fonts/inter-latin.woff2') format('woff2');
font-display: swap;
size-adjust: 105%;
ascent-override: 90%;
}
/* 4. Animate composited properties only */
.card:hover {
transform: translateY(-4px); /* not top / margin / height */
}
The rules that cover most real cases:
- Every
<img>and<video>haswidthandheight, or anaspect-ratio. - Space for a banner, a reviews widget or a map is reserved before its content arrives.
- Nothing is inserted above already-visible content: cookie notices, promo bars and warnings go into an overlay or to the bottom of the viewport.
- The fallback font is metric-matched with
size-adjustandascent-override; otherwise the swap reflows paragraphs. - Animations run on
transformandopacity, never ontop,heightormargin.
Checking CLS only during page load is the most common mistake. Scroll to the bottom, open the menu, wait for lazy-loaded blocks and pop-ups: for real visitors that is exactly where the score accumulates.
Lab vs field: why the numbers disagree
The classic situation: 98 in Lighthouse, red band in Search Console. There is no contradiction — these are two fundamentally different measurements.
| Aspect | Lab | Field |
|---|---|---|
| Source | Lighthouse, WebPageTest, DevTools, synthetic checks | CrUX, your own RUM, the Search Console report |
| Device and network | Fixed emulated conditions | Real phones on real connections |
| Interactions | None: a robot does not click | Present: INP cannot be measured without them |
| Reproducibility | High, good for debugging and CI | Low: statistics, not a single run |
| Data window | An instant snapshot | A rolling window of roughly 28 days |
| INP | Not measured; TBT is the proxy | Measured |
| What it tells you | Where the problem is | Whether people have a problem |
Hence the working order: field data answers "what to fix", lab data answers "why it happens". Starting with Lighthouse and no field data means optimising something that may bother nobody.
The second source of disagreement is data volume. If a page gets little traffic, it may have no field report of its own at all — data is aggregated across a group of similar pages or across the whole origin. A red origin does not prove that the specific page you opened is the guilty one.
The Lighthouse performance score is not Core Web Vitals. It is a weighted summary of lab metrics, including ones that are not part of CWV at all. You can chase a round number indefinitely while the field metrics refuse to move.

How to check Core Web Vitals on your site
The quick route is running the page through the website speed test on enterno.io: it reports load time, server response and bottlenecks, and the result can be saved and compared with the previous measurement. For repeated checks of the same pages use monitoring, and inspect caching and compression headers in the HTTP header checker.
Field data
- Google Search Console, Core Web Vitals report — issues grouped by page template across the whole site. The best starting point: it shows scale.
- PageSpeed Insights — CrUX field data for a specific URL with a lab run alongside it.
- CrUX API — the same data programmatically, convenient for your own dashboard.
curl -s "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/","formFactor":"PHONE"}' | python3 -m json.tool
The response carries a three-band histogram for each metric plus the 75th-percentile value — the exact number that decides whether the page passes.
Lab data
- Chrome DevTools — the Performance and Lighthouse panels, plus a Web Vitals overlay drawn on the page itself.
- Lighthouse CLI — the same thing in a script, suitable for CI.
- WebPageTest — runs from different locations and network profiles.
# One run with a mobile profile, report saved to disk
npx lighthouse https://example.com/ \
--only-categories=performance \
--form-factor=mobile \
--throttling-method=simulate \
--output=json --output-path=./lh.json
# Pull the key metrics out of the report
python3 - <<'PY'
import json
a = json.load(open('lh.json'))['audits']
for k in ('largest-contentful-paint', 'cumulative-layout-shift', 'total-blocking-time'):
print(k, a[k]['displayValue'])
PY
A single Lighthouse run proves nothing: run-to-run variance on the same page easily reaches tens of percent. Take three to five runs and use the median, and compare medians in CI rather than individual runs.
Checking by hand in the browser
You can read the metrics on a live page with no tooling at all — straight from the console:
// LCP: the last recorded candidate
new PerformanceObserver((list) => {
const e = list.getEntries().pop();
console.log('LCP:', Math.round(e.startTime), 'ms', e.element);
}).observe({ type: 'largest-contentful-paint', buffered: true });
// CLS: accumulated shift, excluding user-driven movement
let cls = 0;
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (!e.hadRecentInput) cls += e.value;
}
console.log('CLS:', cls.toFixed(3));
}).observe({ type: 'layout-shift', buffered: true });
// Long tasks — the main source of a poor INP
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
console.log('long task:', Math.round(e.duration), 'ms');
}
}).observe({ type: 'longtask', buffered: true });
For a comparison of external services, see the round-up of the best website speed test tools.
How to collect real user metrics (RUM)
CrUX only covers Chrome users who opted into usage statistics, and it will not give you the breakdowns you actually need: by page template, by traffic source, by release. Your own RUM removes both limits.
The official web-vitals library computes the metrics exactly the way Chrome does and reports a value once it is final:
import * as webVitals from 'web-vitals';
function send(metric) {
const body = JSON.stringify({
name: metric.name, // LCP | INP | CLS | TTFB | FCP
value: Math.round(metric.value),
rating: metric.rating, // good | needs-improvement | poor
id: metric.id,
path: location.pathname,
conn: navigator.connection ? navigator.connection.effectiveType : null,
});
// sendBeacon survives the page being unloaded
if (navigator.sendBeacon) navigator.sendBeacon('/api/rum', body);
}
webVitals.onLCP(send);
webVitals.onINP(send);
webVitals.onCLS(send);
webVitals.onTTFB(send);
Things that matter when you roll your own:
- Report percentiles, not averages. An average hides the tail: half your audience can be suffering while the mean still looks acceptable. p75 is the minimum; p95 is useful.
- Segment by device. A single site-wide number almost always masks a mobile-only failure.
- Tag by release version. Otherwise a regression surfaces weeks later with no clue what caused it.
- Collect the minimum. Full URLs with query strings, user identifiers and form contents have no place in performance telemetry. Under GDPR and similar regimes that is personal data you now have to protect for no measurement benefit — strip query strings and use the path only.
The general methodology is covered in the guide to real user monitoring.
What to fix first: from symptom to fix
A triage table. The symptom comes from a report, the check from a tool, the fix goes into code.
| Symptom | Likely cause | How to check | What to do |
|---|---|---|---|
| Poor LCP, high TTFB | Slow backend, no page cache, distant origin | curl -w time_starttransfer, application logs | Cache the HTML, add a CDN, index the database, enable HTTP/2 or HTTP/3 |
| Poor LCP, normal TTFB | Heavy image or late resource discovery | DevTools Performance, PSI diagnostics | AVIF/WebP, preload, fetchpriority="high", remove lazy |
| LCP jumps between runs | Different elements win the LCP candidacy | Several consecutive runs, compare the node | Stabilise the first viewport, drop fade-in effects |
| Poor INP with good LCP | Heavy handlers, long tasks, third-party scripts | longtask observer, Performance panel | Split tasks, use a Web Worker, defer third-party widgets |
| Poor INP on mobile only | Weaker CPU, the same JS takes longer | DevTools with 4–6x CPU throttling | Ship less JS, remove redundant re-renders |
| Poor CLS during load | Media without dimensions, font swap | layout-shift observer, Layout Shift Regions | width/height, aspect-ratio, metric-matched fallback font |
| Poor CLS while scrolling | Lazy loading, banners, infinite scroll | Manually scroll the whole page | Reserve space, min-height, overlay instead of injection |
| Field red, lab green | Real devices and networks are slower than the rig | Search Console, CrUX, your own RUM | Look at mobile p75, not the overall score |

Core Web Vitals and SEO: what they do and do not do
Google does take page speed and usability into account in ranking — that is a confirmed part of the page experience signals. But it is one signal among many, and it does not outweigh how well the page answers the query.
What that means in practice:
- Green metrics will not lift an irrelevant page. If the content does not answer the query, a perfect LCP will not fix that.
- Poor metrics hurt more often than good ones help. The red band also means bounces: some visitors leave before the paint completes and never reach the content at all.
- The effect is non-linear. Moving from red to amber is normally worth far more than polishing an already-green metric from 2.1 s to 1.9 s.
- Changes are not immediate. Field data is a rolling window of roughly four weeks, so a report shifts gradually after a release.
Nobody, Google included, promises specific positions in exchange for better Core Web Vitals. The honest framing is this: you are removing a technical obstacle that stops part of your audience from reaching the content. Traffic growth is a consequence, not a guarantee.
A sensible order of work is content and technical SEO first — indexing, canonical URLs, clean redirects — and Core Web Vitals second, starting with the page templates that carry the most visits.
Common mistakes
- Optimising the score instead of the metrics. The Lighthouse score and Core Web Vitals are different things. Ranking looks at field metrics.
- Testing the home page only. Traffic usually lands on product pages and articles, which have different markup and different problems.
- Testing on your own hardware. A fast laptop on stable Wi-Fi shows a reality your audience does not have. Turn on CPU throttling in DevTools.
- Blanket lazy loading. A global
loading="lazy"in the template almost always catches the LCP image too. - Point fixes with no regression guard. Two releases later somebody re-adds the widget and the metric slides back. You need a repeatable measurement.
- Treating third-party scripts as immovable. Other people's widgets are a frequent cause of both INP and CLS. They can be deferred, loaded on demand, or removed.
- Ignoring one of the three metrics. The assessment passes only when all three do, at the same time.
Keeping the result: monitoring and a performance budget
Optimisation decays without a control loop: a new banner, a library upgrade, a cache that quietly stopped working — and a month later the metrics are back where they started. The minimum viable setup:
- A budget. Write down the limits: LCP on key templates, JavaScript weight, number of third-party origins. Exceeding one is a discussion, not a silent merge.
- A check in CI. A Lighthouse run against a handful of representative URLs that fails the build when the budget is blown. It catches obvious regressions before production.
- Field metrics after release. Your own RUM or CrUX, tagged with the release version.
- External availability and response checks. Regular monitoring of key pages with an alert when response time grows: more often than not LCP degrades because TTFB crept up, not because of markup.
While you are there, verify that caching headers and compression are still in place — both are easy to lose during a migration or a config change:
# Caching and compression headers for a static asset
curl -sI -H "Accept-Encoding: br,gzip" https://example.com/assets/app.css \
| grep -iE "cache-control|content-encoding|vary|age|etag"
# Protocol and total response time for the HTML document
curl -s -o /dev/null -w "proto: %{http_version} ttfb: %{time_starttransfer}s\n" https://example.com/
You can break a response down header by header without a console in the HTTP header checker, and compare speed before and after your changes with the speed test. Compression itself is covered in the article on gzip and brotli.
Frequently asked questions
Which metrics are Core Web Vitals
Three: LCP (how fast the main element paints, good is up to 2.5 s), INP (responsiveness to interactions, up to 200 ms) and CLS (visual stability, up to 0.1). FID has been removed from the set and replaced by INP. Google revises both the set and the thresholds periodically.
How do I check page speed and Core Web Vitals for free
Field data comes from the Core Web Vitals report in Google Search Console and from PageSpeed Insights. Lab data comes from the Lighthouse panel in Chrome DevTools. For a quick load-time and server-response measurement you can save and re-run, use the speed test on enterno.io.
What counts as a normal page speed
Useful reference points: TTFB under 800 ms, LCP under 2.5 s at the 75th percentile of mobile visits, INP under 200 ms, CLS under 0.1. Total "page load time" on its own says very little — what matters is when the first viewport became visible and usable, not when the last analytics script finished downloading.
Why is Lighthouse showing 95 while Search Console shows the red band
Lighthouse is a synthetic run on a fixed device and network profile; Search Console reports statistics from real visits over a rolling window. Real audiences have slower phones and worse networks, and INP is not measured in the lab at all because the robot never clicks. The gap is expected — trust the field data.
How long after an optimisation do the metrics change
Field data updates with a lag: the observation window is around 28 days, so improvements appear gradually rather than overnight. Your own RUM shows the effect within a day. If nothing has moved after a month, the change most likely did not touch the page template that actually receives the traffic.
Do Core Web Vitals matter for search engines other than Google
Core Web Vitals are Google's metrics and other engines do not report them. But speed and stability affect visitor behaviour — bounces, pages per session, returns — and behavioural signals are used broadly. The work pays off regardless of where the traffic comes from.
Should I chase a zero CLS and a sub-second LCP
No. These are threshold metrics: once a page is in the green band, further polishing returns far less than moving the next page template into green. Clear the red bands on your highest-traffic templates first.
Checklist
- The Core Web Vitals report in Search Console is open, with mobile and desktop distributions visible.
- Three to five highest-traffic page templates are selected — the work targets those, not the home page.
- The real LCP element has been identified on each template (DevTools Performance, not a guess).
- The LCP image has no
loading="lazy", carriesfetchpriority="high"and has explicit dimensions. - TTFB has been measured with
curland sits around 800 ms or below. - Compression (brotli or gzip) is on and static assets carry sensible
Cache-Controlheaders. - Long tasks were found with the
longtaskobserver; the heaviest are split or moved into a Web Worker. - Third-party scripts are inventoried: deferred, loaded on demand, or removed.
- Every image and embed has dimensions set or space reserved.
- The page has been scrolled end to end with no shifts from lazy-loaded blocks or pop-ups.
- Field metric collection is in place (own RUM or a regular CrUX export), segmented by device.
- A performance budget and a CI check exist so regressions do not reach production.
- Key pages are under monitoring with alerts on rising response time.
Start by measuring: run your key pages through the speed test, inspect caching headers in the HTTP header checker, and put what matters under monitoring — so you are not hunting for the result of your optimisation all over again a month from now.