dotvitals

Core Web Vitals explained

Updated ·15 min read

Core Web Vitals are three metrics Google publishes to describe what a page actually felt like to the people who loaded it: how long the main content took to appear, how quickly the page responded when they touched it, and how much it moved around underneath them. They are deliberately few, deliberately user-facing, and deliberately measured from real visits rather than from a test run in a data centre.

They are also widely misread. A score from a lab tool is not the same thing as a Core Web Vitals assessment, a missing assessment is not a bad one, and a page can have a comfortably fast median while failing outright — because the statistic Google uses is the 75th percentile, not the average. Most of the confusion in this area comes from those three points, so this guide spends time on them rather than on a list of tips.

Every threshold, definition and date below was checked against Google's own documentation on 17 September 2026 — web.dev for the metric definitions and thresholds, and the Chrome UX Report documentation for how the field data is collected. Where a figure is a snapshot that can move, it says so. Our scanner's performance section reports both field and lab data and is scored on the same published thresholds, and it runs as part of the full Domain Health Check.

Check yours now

The three metrics, and the thresholds as published

As of 17 September 2026, the Core Web Vitals are Largest Contentful Paint, Interaction to Next Paint and Cumulative Layout Shift. Google's published thresholds, each with a good band and a poor band and a needs-improvement range between them:

  • Largest Contentful Paint (LCP): good at or below 2,500 ms, poor above 4,000 ms. It measures loading — specifically the render time of the largest image or text block visible in the viewport, relative to when the page started loading.
  • Interaction to Next Paint (INP): good at or below 200 ms, poor above 500 ms. It measures responsiveness — the time from a user interaction until the next frame is painted, assessed across the whole visit rather than only the first interaction.
  • Cumulative Layout Shift (CLS): good at or below 0.1, poor above 0.25. It measures visual stability. CLS is unitless: it is the largest burst of unexpected layout shift during the visit, where each shift is scored by how much of the viewport moved and how far.

INP is the successor to First Input Delay, which it replaced as a Core Web Vital in March 2024. The difference is not cosmetic: FID measured only the delay before the first interaction began to be processed, while INP measures the full journey from any interaction to the next painted frame. A site that scored well on FID can score badly on INP without having changed, because INP counts work FID never looked at.

Two further metrics appear in the same reports and are not Core Web Vitals: Time to First Byte (good at or below 800 ms, poor above 1,800 ms) and First Contentful Paint (good at or below 1,800 ms, poor above 3,000 ms). They are diagnostic inputs — TTFB is inside LCP, and FCP sits between the two — rather than things Google assesses you on.

The 75th-percentile rule, and why a fast median is not enough

Google assesses each metric at the 75th percentile of page loads, not the mean or the median. Three visits in four were at least this good; one in four was worse. web.dev's own explanation of the thresholds says the choice is a balance between making sure most visits meet the target and not letting a handful of extreme outliers decide the verdict.

The consequence is the thing people find unfair and it is the entire point: a site with a fast median can fail. Your median visitor may be on a recent phone on a good connection. The 25% who are not — an older device, a congested mobile network, a long distance from your origin — are real people having a real experience of your site, and the 75th percentile is what makes them count.

The assessment is also all-or-nothing across the three metrics. A page or origin passes the Core Web Vitals assessment only when LCP, INP and CLS are all in the good band at the 75th percentile. One metric in the needs-improvement band fails the whole assessment, which is why a report can read as failing while most of the numbers look reasonable. Our report states the assessment on exactly those terms.

Alongside the percentile, the data gives a distribution across the three bands — what share of visits were good, needs improvement and poor. Read it. A metric where 20% of visits are poor is a very different site from one where 24% are merely mediocre, even though both may sit in the same band at the 75th percentile, and the fixes are different too.

Field data versus lab data, and why they disagree

Field data is what real users experienced. For Core Web Vitals this means the Chrome UX Report (CrUX), a public dataset of real Chrome visits aggregated over a trailing 28-day window. It is the dataset Google's own page-experience reporting is built on, and it is published under CC BY 4.0.

Lab data is one page load performed by a tool, on hardware and a network the tool chose, under throttling the tool applies. Lighthouse is the best-known source; ours is a headless Chromium we drive ourselves. It is reproducible, it can attribute a problem to a specific file, and it is not a sample of your visitors.

They disagree constantly, and almost always for understandable reasons. A lab run measures a cold load of one URL from a well-connected machine; your field data averages a mix of cold and warm caches, a mix of pages, a mix of devices and a mix of networks. A lab run sees no third-party consent banner because the tool did not click anything; real visits do. A lab run cannot produce an INP at all in any meaningful sense, because nobody interacted with the page.

Time to First Byte shows the gap most starkly. In the field it measures the whole journey — redirects, DNS, connection setup, TLS negotiation and the server's own processing, from the visitor's own network. A synthetic check from a data centre sees one well-connected request and routinely reports a fraction of what real users live with. Where the two disagree, the field number is the one your visitors experience.

The working rule, and the one our report is built around: field data tells you whether there is a problem, lab data tells you which resource is responsible. Use the lab run to find the cause and the field data to confirm the cure. And remember that field data lags — a fix deployed yesterday will not move a 28-day trailing window until it has been in front of users for most of it.

No field data is not a verdict

CrUX only publishes a dataset for a page or an origin once it has enough real Chrome visits to be statistically sound and to avoid identifying individual users. A site below that threshold gets no entry. That is a fact about traffic volume, not about speed: the site is not slow and it is not fast, and nobody knows.

Our scanner treats this as its own finding, deliberately separate from every metric rule, so that no data can never be rendered through the same component as a pass or a fail. It deducts nothing. Awarding a pass would be claiming the site is fast on the strength of no evidence; failing it would penalise a site for not being popular. Neither is defensible.

A few other things produce an empty dataset besides low traffic: a page that is new, one behind authentication, and — worth knowing — a URL that redirects, because CrUX keys on the URL after redirects, so a dataset may exist under a different URL than the one you entered. We ask for the specific URL first and fall back to the whole origin, and every finding records which granularity it came from, because your home page and every page on your site are different claims.

There is a second empty case that is entirely ours. The CrUX API requires an API key on every request. If the deployment you are using has no key configured, no query was made at all. That is a fact about our deployment, not about your site, and our report says so in those words rather than leaving a blank section that could be mistaken for a clean one. The same honesty applies to a quota refusal or a timeout: the finding says the query failed and asserts nothing about your performance either way.

Fixing LCP

LCP is a sum of four parts, and the fix depends entirely on which one dominates: time to first byte, resource load delay (how late the browser discovered the resource), resource load time (how long the bytes took), and element render delay (what blocked painting once it arrived). Find which part is yours before optimising anything.

First, identify the LCP element. It is almost always the hero image, a CSS background image, or the first substantial block of text. Lab tools name it; Chrome DevTools shows it under Performance.

  • If TTFB is over 800 ms, fix that first. No front-end change recovers time already spent waiting for the first byte. Remove entry redirects, cache the HTML at the edge where the page allows it, and profile the server-side request for slow queries and synchronous third-party calls.
  • If it is an image: serve it at the size it is displayed, in AVIF or WebP, give it fetchpriority of high, and preload it so it is not discovered late in the document. Never lazy-load the LCP image. A loading attribute of lazy on the hero image is the single most common cause of a poor LCP, and it is usually added by a plugin that lazy-loads everything indiscriminately.
  • Remove render-blocking CSS and synchronous JavaScript from the critical path. Those delay the render, not just the download, so the element can be fully downloaded and still not painted.
  • If it is text, the usual culprit is a web font. font-display of swap or optional paints the text in a fallback face immediately rather than holding the paint until the font arrives.
The LCP image: discovered early, sized, modern format, never lazy
<link rel="preload" as="image" href="/hero.avif" fetchpriority="high">

<img src="/hero.avif" alt="" width="1200" height="600"
     fetchpriority="high" decoding="async">

<!-- Wrong, and very common. The lazy attribute defers the one image
     that must not be deferred. -->
<!-- <img src="/hero.jpg" loading="lazy"> -->

Fixing INP

INP has three components, and Google's documentation names them: input delay (the main thread was busy when the user acted), processing duration (your event handlers ran long), and presentation delay (rendering the result was expensive). Long JavaScript tasks are the usual cause of all three, because they are all competing for the same single main thread.

Start by finding the long tasks rather than guessing. In Chrome DevTools, record a Performance trace while you perform the slow interaction, and look for tasks over 50 ms on the main thread. Those are what the user's tap was queued behind.

  • Break long work into smaller pieces and yield to the main thread between them, so the browser gets a chance to paint. Do the visible part first: update the interface in response to the interaction, then do the expensive bookkeeping after the next frame.
  • Remove or defer third-party scripts. Analytics, tag managers, chat widgets and consent banners are disproportionately represented in long-task traces, and the page does not depend on them to render.
  • Cut the amount of JavaScript that has to be parsed and executed at all. Parse and compile happen on the main thread too, before any of your code runs.
  • Watch for large DOM updates. Presentation delay grows with how much the browser has to lay out and paint, so an interaction that re-renders a thousand rows is slow even with a trivial handler.

scheduler.yield is available in Chromium-based browsers; a promise wrapped around setTimeout with a zero delay is the fallback elsewhere. The pattern matters more than the API: paint before you compute.

Paint the response first, then do the expensive work
button.addEventListener('click', async () => {
	renderImmediateFeedback();      // cheap, visible, this frame
	await scheduler.yield();        // let the browser paint
	await doTheExpensiveWork();     // the user is no longer waiting on it
});

Fixing CLS

The causes of layout shift are a short list and they repeat across nearly every site: media with no declared dimensions, ads and embeds injected into space that was not reserved, web fonts that swap and re-flow the text, and content inserted above existing content — cookie banners, notification bars, late-arriving personalisation.

  • Give every image, video and iframe explicit width and height attributes, or a CSS aspect-ratio. The browser then reserves the correct box before the file arrives. This does not conflict with responsive CSS: with max-width of 100% and height of auto, the browser uses the attributes only to compute the aspect ratio, which is exactly what reserves the space.
  • Reserve the exact dimensions of every ad slot. A fixed-height container that is empty until the ad loads causes no shift; an ad that expands the page causes a large one.
  • Load fonts with font-display of optional or swap, plus a fallback matched with size-adjust, so the text does not re-flow when the web font arrives.
  • Never insert content above existing content after load. Put banners in an overlay, or reserve their height from the first paint.
  • Animate with transform and opacity rather than with properties that trigger layout, such as top, height or margin.

To see it directly: in Chrome DevTools, under Performance, enable Layout Shift Regions and reload. Nothing should flash after the first paint.

Reserve the box before the bytes arrive
<img src="/photo.avif" alt="" width="800" height="450">

<!-- An ad slot whose height is reserved cannot shift the page. -->
<div class="ad-slot" style="min-height: 250px; contain: layout;"></div>

<style>
	/* Or reserve by ratio, which survives responsive resizing. */
	.media { aspect-ratio: 16 / 9; width: 100%; }
</style>

How dotvitals scores performance

Our performance section has two halves and they are scored asymmetrically on purpose. Field data from CrUX is authoritative and is what we score. Lab measurements are reported and deduct nothing, because scoring both would be counting one problem twice, and doing it the second time with the weaker evidence.

The scored field rules use Google's own thresholds, not ours: a needs-improvement result is reported as a warning and a poor result as a failure. LCP and INP deduct 12 points each, CLS 10, TTFB 8 and FCP 6, all within a single capped performance group so a slow site is not punished five times over for one root cause.

The lab half publishes opportunities and never a score, and that needs explaining because it is unusual. We do not withhold the score sometimes — we do not calculate one at all, on any run. The 0-100 number people recognise is not a measurement: it is the output of Lighthouse's Lantern simulation applied to a trace, run through a scoring curve calibrated against a corpus of real sites. Our lab runner drives Chromium directly over the DevTools protocol and implements neither. Publishing a number that looked like Google's but was not computed the way Google computes it would be worse than publishing none, because you would compare the two. So the report says there is no score, every time, and publishes the opportunities instead.

That is not a hedge. The opportunities are the measurements that do not depend on CPU speed — the transfer size of every resource, which resources block rendering, which images are larger than the space they are displayed in, which text responses arrived uncompressed, and how much of the shipped JavaScript went unused. Those are facts about what your server sent, identical on any machine. Your score is 47 is not.

Those lab opportunities are scored, because bytes are bytes: render-blocking resources deduct 10, oversized images 8, uncompressed text subresources 8, excessive unused JavaScript 8, short cache lifetimes on fingerprinted assets 6, and legacy image formats 5. If you want the headline score, run PageSpeed Insights against the same URL. It executes on Google's own infrastructure and computes the number the way Google defines it, which is exactly why we would rather point you at it than imitate it.

If the lab audit itself could not run — it drives a real Chromium instance on a separate host, which a Cloudflare Worker cannot do — the report says so and emits nothing else from that half. No metric, no opportunity, and above all no pass from a run that did not happen. The field half is independent and may still be present, because it needs no browser of ours.

How to verify a fix actually landed

Two checks, answering two different questions, and a rollout that skips the second is conducted blind.

Immediately after deploying, confirm the change is present using lab tools and the browser: re-run the audit and check that the render-blocking count dropped, the image is now AVIF at the right width, the long task is gone from the Performance trace. This proves the change shipped. It does not prove it helped your visitors.

Then wait. Field data uses a trailing 28-day window, so the number will not move until the fix has been in front of real users for most of it. Expect a partial improvement to appear after a week or two and the full effect at about a month. Re-running the scan the same afternoon and seeing no change means nothing at all, and the temptation to conclude the fix did not work and revert it is the most expensive mistake in this whole area.

If you want faster feedback than 28 days, collect your own field data with the web-vitals JavaScript library and send it to your own endpoint. It measures the same metrics with the same definitions, on your visitors, with whatever latency you choose. CrUX remains the dataset Google's public reporting uses, but it is not the only way to see real-user numbers.

One caution on attribution: if you changed five things in one deploy, the field data will not tell you which one helped. Ship performance changes in small batches when you can, for the same reason you would anywhere else.

What commonly goes wrong

  • Optimising a headline speed score instead of the metrics. Any such score is a weighted blend of simulated numbers from one run on one machine; chasing it can lead to changes that move the score and not the experience. This is part of why our own report does not publish one.
  • Lazy-loading the hero image. A blanket lazy-load plugin applied to every image will include the LCP element, and it is the most common single cause of a poor LCP we see.
  • Reading no field data as a problem, or as a clean bill of health. It is neither. It almost always means the site does not get enough Chrome visits to reach the reporting threshold.
  • Judging a fix by re-running the scan the same day. The field window is 28 days. Nothing you deployed this morning is in it yet.
  • Treating a good median as passing. The assessment is at the 75th percentile, and it requires all three metrics to be in the good band at once.
  • Fixing CLS by shrinking the shifts rather than reserving the space. Making an ad smaller still moves the content under it. A reserved container does not.
  • Ignoring TTFB because it is not a Core Web Vital. It is inside LCP. A server taking 1.5 seconds to answer has already spent more than half the LCP budget before the browser has seen a single byte of your HTML.

Check your domain with the domain health check