Web Performance, Part 1: What fast actually means
Before you can make a page fast, you need to know what "fast" means. A plain guide to the Core Web Vitals, how they are scored, and how to capture them yourself.
Picture a checkout page. It has a product summary with an image, a payment form, a Pay button and a few third-party scripts. On your laptop it loads in two seconds. A customer on a mid-range phone taps Pay, sees nothing happen, and taps again.
Your dashboard says the page "loaded". For that customer, it was still broken. This post is about the numbers that would have told you.
In 2025 and 2026, most of my work was optimising apps and web pages. Before that, I had never paid much attention to it. So I got curious and started learning how people make pages fast.
What I found surprised me. In my experience, most of the time web performance has little to do with optimising code. It is about how the page loads, when each element triggers a download, and how big those downloads are.
That is why this series starts with measurement. You can't fix what you can't name.
This is Part 1 of Web Performance in Practice, a four-part series. Each post stands on its own, and all four use the same checkout page as the running example.
Why speed matters
Before we measure anything, let's see what a slow page costs. There are three costs.
Experience. A slow page feels broken. Your customer doesn't know your bundle size. They only know that tapping Pay did nothing.
Conversion. Several companies have shared what they gained after improving their vitals. These results are company-reported, and each team changed more than one thing. Still, the numbers are big:
| Company | What improved | Reported result |
|---|---|---|
| Vodafone (Italy) | LCP improved by 31% | 8% more sales |
| Lazada | LCP improved 3x | 16.9% higher mobile conversion rate |
| Agrofy Market | LCP improved by 70% | 76% fewer load abandonments |
Source: The business impact of Core Web Vitals on web.dev.
Search. Google recommends good Core Web Vitals for success in Search. See Google's guidance. It is one signal among many, and good scores don't guarantee a ranking.
So speed pays off. The next question is how to measure it, and for years the answer was a pair of browser events.
Why "load" stopped being useful
The two events are DOMContentLoaded and load. DOMContentLoaded fires when the HTML is parsed and deferred scripts have run. load fires when every resource, such as images and stylesheets, has finished.
You can see both yourself:
- Open DevTools and go to the Network tab.
- Reload the page.
- Read the summary bar at the bottom. It shows the
DOMContentLoadedandLoadtimes. - In the waterfall, a blue line marks
DOMContentLoadedand a red line marksload.
With a client-rendered app, these events mislead you in both directions. A single-page app sends HTML like this:
<body>
<div id="root"></div>
<script src="/bundle.js"></script>
</body>DOMContentLoaded fires almost at once, because there is little HTML to parse. But the customer sees a blank page until the JS bundle downloads, runs and fetches data.
The reverse happens too. A tracking pixel at the bottom of the page can delay load by seconds, even if the customer could already pay.
Both events describe what the browser did. Neither describes what the customer saw. For more on where HTML gets built, see my post on CSR, SSR, SSG and ISR.
To fix that, we need metrics that watch the customer's side. There are five of them.
Five metrics, five questions
Each modern metric answers a question your customer is silently asking.
| The customer asks | Metric | Core Web Vital? |
|---|---|---|
| Did the server answer? | TTFB, Time to First Byte | No |
| Is anything happening? | FCP, First Contentful Paint | No |
| Can I see the main thing yet? | LCP, Largest Contentful Paint | Yes |
| Does the page hold still? | CLS, Cumulative Layout Shift | Yes |
| Does it react when I tap? | INP, Interaction to Next Paint | Yes |
Here is one load of the checkout page, drawn as a waterfall, with all five metrics marked on it. The top rows are network requests. The bottom row is the main thread.
Here is what each metric means on the checkout page:
- TTFB is the time from the start of navigation until the first byte of HTML arrives. It includes redirects, DNS, connection setup and server time.
- FCP is when any content first appears: text, an image or a non-white canvas.
- LCP is when the largest image, video or text block in the viewport finishes rendering. Here, that is probably the product image.
- CLS measures unexpected movement. It scores the promo banner that pushes the card form down just as you aim for it.
- INP measures how long the page takes to show a response after a click, tap or key press. It scores the Pay button.
TTFB and FCP are not Core Web Vitals. They help you diagnose LCP problems.
Don't worry if these still feel new. We cover each one in detail later in the series.
For now, there is one more question: how fast is fast enough?
The thresholds
Google gives each metric a target. It assesses each one at the 75th percentile of page loads, split by mobile and desktop. If 75% of loads are at or under the "good" line, the page passes that metric. To pass the Core Web Vitals, a page must pass all three.
| Metric | Good | Needs improvement | Poor |
|---|---|---|---|
| LCP | ≤ 2.5 s | ≤ 4.0 s | > 4.0 s |
| INP | ≤ 200 ms | ≤ 500 ms | > 500 ms |
| CLS | ≤ 0.1 | ≤ 0.25 | > 0.25 |
| FCP | ≤ 1.8 s | ≤ 3.0 s | > 3.0 s |
| TTFB | ≤ 0.8 s | ≤ 1.8 s | > 1.8 s |
Sources: LCP, INP, CLS, FCP and TTFB. The TTFB range is a rough guide, not a strict target.
Why the 75th percentile and not the average? Later in the series, we answer that with numbers.
One of these three vitals is newer than the rest. If you read about web vitals a few years ago, you will notice a missing name.
Wait... what about FID?
First Input Delay (FID) used to be the third vital. On March 12, 2024, INP replaced it as a Core Web Vital. See the announcement.
FID had two blind spots. It looked only at the first interaction on the page. And it measured only the wait before the browser started your handler. It ignored the handler and the time to paint the result.
INP looks at every click, tap and key press during the visit, from the input until the next frame is painted. It reports roughly the worst one. Scrolling, hovering and zooming don't count.
For the Pay button, that is the right question. Not "was the first tap quick?", but "did the tap that mattered feel quick?"
Here is the same visit with three taps, scored both ways:
That covers INP. The last vital, CLS, works differently.
How CLS is scored
CLS is the odd one out. It is a score, not a time. Each layout shift gets a score:
layout shift score = impact fraction × distance fraction
- Impact fraction: the area of all elements that moved, before and after the shift, as a share of the viewport. Only the visible part counts.
- Distance fraction: the longest distance any element moved, as a share of the viewport's larger side (height on a phone).
Shifts are then grouped into session windows. A window ends when there is a gap of 1 second or more between shifts, or when it reaches 5 seconds. CLS is the score of the worst window, not the sum of all shifts.
One more rule: shifts within 500 ms of a user input don't count, such as a menu opening after a tap.
The checkout page in the waterfall above scores 0.13 from two small shifts, which is "needs improvement". Later in the series, we walk through that math step by step and remove both shifts.
Capture the metrics yourself
Enough theory. Before we wrap up, let's measure these metrics on a real page. You don't need a 3rd-party vendor to begin, so start small and work up.
Timing your own code
performance.now() gives a high-resolution timestamp. performance.mark() and performance.measure() name the spans you care about. Paste this into the DevTools console:
const t0 = performance.now();
performance.mark('pay-start');
await new Promise((resolve) => setTimeout(resolve, 120)); // stand-in for real work
performance.mark('pay-end');
performance.measure('pay-flow', 'pay-start', 'pay-end');
console.log('manual:', Math.round(performance.now() - t0), 'ms');
console.log('measure:', Math.round(performance.getEntriesByName('pay-flow')[0].duration), 'ms');Named measures also show up on the Timings track in the DevTools Performance panel.
That times your own code. The browser also records metrics of its own.
Observing the browser's own metrics
PerformanceObserver lets you subscribe to those entries. Add buffered: true to replay entries from before your code ran. Without it, you miss early metrics such as LCP.
Use the type option when you need buffered. It takes one entry type per call. The entryTypes option takes several types, but you can't combine it with buffered. See the MDN reference.
Paste this fragment into the console to log LCP candidates and a hand-rolled CLS:
// LCP: the browser reports a new candidate each time a larger element renders.
new PerformanceObserver((list) => {
const entries = list.getEntries();
const last = entries[entries.length - 1];
console.log('LCP candidate:', Math.round(last.startTime), 'ms', last.element);
}).observe({ type: 'largest-contentful-paint', buffered: true });
// CLS: group shifts into session windows, keep the worst window.
let windowValue = 0;
let windowStart = 0;
let windowLast = 0;
let cls = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue; // shifts right after input do not count
const continuesWindow =
windowValue > 0 &&
entry.startTime - windowLast < 1000 &&
entry.startTime - windowStart < 5000;
if (continuesWindow) {
windowValue += entry.value;
} else {
windowValue = entry.value;
windowStart = entry.startTime;
}
windowLast = entry.startTime;
cls = Math.max(cls, windowValue);
}
console.log('CLS so far:', cls.toFixed(3));
}).observe({ type: 'layout-shift', buffered: true });I wouldn't hand-roll INP. Its rules are fiddly: grouping events by interaction, then picking the worst one. Use the library.
Using the web-vitals library
web-vitals comes from the Chrome team. It handles edge cases such as background tabs, back/forward cache restores and iframes.
npm install web-vitalsimport { onLCP, onCLS, onINP } from 'web-vitals';
function log({ name, value, rating }) {
const shown = name === 'CLS' ? value.toFixed(3) : `${Math.round(value)} ms`;
console.log(`${name}: ${shown} (${rating})`);
}
// reportAllChanges: report as values update, not only when the page is hidden.
// Handy while developing. In production, leave it off to send fewer events.
onLCP(log, { reportAllChanges: true });
onCLS(log, { reportAllChanges: true });
onINP(log, { reportAllChanges: true });No bundler? Import from a CDN inside a <script type="module"> tag instead: import { onLCP, onCLS, onINP } from 'https://unpkg.com/web-vitals@5?module';
Each callback gets a metric object. The useful fields are name, value, rating (good, needs-improvement or poor), delta and id.
To test INP, click around the page. INP has no value until you interact.
Without writing code
Chrome DevTools shows live LCP, CLS and INP for the current page. Open the Performance panel and look at the live metrics before you record anything. Panel labels change between Chrome versions, so look for the three metric names.
One last caveat before we finish: not every browser reports every metric.
Browser support
The Core Web Vitals are Chromium-first. As of October 2026, the web-vitals README lists these limits:
| Metric | Chromium | Firefox | Safari |
|---|---|---|---|
LCP (onLCP) | Yes | Yes | Yes |
INP (onINP) | Yes | Yes | Yes |
CLS (onCLS) | Yes | No | No |
This changes over time, so check the README and MDN before you rely on a metric in a given browser. Also remember that the data Google uses for ranking comes from Chrome users. In practice, if you only test in Safari or Firefox, you won't see CLS.
Key takeaways
Here is the short version of everything above.
DOMContentLoadedandloaddescribe what the browser did. LCP, CLS and INP describe what the customer saw and felt.- Core Web Vitals targets, at the 75th percentile: LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1.
- CLS is the worst session window of layout shifts, not the sum of all shifts. Each shift is impact fraction × distance fraction.
- INP replaced FID in March 2024 because it measures every interaction, through to the next paint.
- You can measure all of this yourself with
PerformanceObserverandweb-vitals. Usebuffered: trueso you don't miss early entries.
Next in the series
In the next post, we look at how to measure well. We cover lab data against field data, why averages hide bad experiences, and which tools to use.
Further reading
- Web Vitals overview on web.dev
- Largest Contentful Paint, Cumulative Layout Shift and Interaction to Next Paint
- Time to First Byte and First Contentful Paint
- PerformanceObserver on MDN
- The
web-vitalslibrary - Chrome UX Report (CrUX)
- Web Performance Fundamentals, v2 by Todd Gardner, at master.dev