17 Oct, 2026 · 9 min read · Web · Performance

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:

CompanyWhat improvedReported result
Vodafone (Italy)LCP improved by 31%8% more sales
LazadaLCP improved 3x16.9% higher mobile conversion rate
Agrofy MarketLCP 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:

  1. Open DevTools and go to the Network tab.
  2. Reload the page.
  3. Read the summary bar at the bottom. It shows the DOMContentLoaded and Load times.
  4. In the waterfall, a blue line marks DOMContentLoaded and a red line marks load.

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 asksMetricCore Web Vital?
Did the server answer?TTFB, Time to First ByteNo
Is anything happening?FCP, First Contentful PaintNo
Can I see the main thing yet?LCP, Largest Contentful PaintYes
Does the page hold still?CLS, Cumulative Layout ShiftYes
Does it react when I tap?INP, Interaction to Next PaintYes

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.

MetricGoodNeeds improvementPoor
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-vitals
import { 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:

MetricChromiumFirefoxSafari
LCP (onLCP)YesYesYes
INP (onINP)YesYesYes
CLS (onCLS)YesNoNo

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.

  • DOMContentLoaded and load describe 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 PerformanceObserver and web-vitals. Use buffered: true so 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