← Back to Blog

Core Web Vitals Explained: Measuring & Fixing LCP, INP and CLS

Three Metrics, Three Measurement Models

「The site feels slow」 is where every performance problem starts — unmeasurable and unfixable. Google splits that feeling into three quantifiable metrics — LCP (Largest Contentful Paint, loading), INP (Interaction to Next Paint, responsiveness), CLS (Cumulative Layout Shift, visual stability) — with pass lines of 2.5 s / 200 ms / 0.1.

This article focuses on the three things that go wrong most often: how lab and field data differ, the ranked causes for each metric, and a copy-paste measurement command (demonstrated against this site's production environment).


Metric What it measures Pass line (P75) Usual suspects
LCP Largest content element rendered ≤ 2.5 s Slow TTFB, unoptimized hero image, blocking fonts
INP Input to next paint across all interactions ≤ 200 ms Long tasks, forced sync layout, huge DOM
CLS Cumulative unexpected layout shifts ≤ 0.1 Unsized images/ads, dynamically injected content

All three are judged at the P75 percentile (75% of visits pass), not the average — averages get inflated by good devices and hide the bad-user experience.

Lab vs Field: The Division of Work

Lab (Lighthouse / DevTools) Field (CrUX / web-vitals library)
Environment Fixed device/network/location, reproducible Real devices/networks, not reproducible
Sampling Single run 28-day aggregate
Metrics All (incl. LCP/CLS) LCP/INP/CLS/FCP/TTFB
Use Locate problems (call stacks, waterfalls) Confirm impact (real distribution)
Trap Nice device, optimistic INP Small samples lack segmentation

The correct flow: field data shows 「INP P75 is over」 → reproduce that interaction in the lab → find the long task → fix → verify in the lab → wait for CrUX to refresh (28-day rolling window) to confirm. Declaring victory after a single Lighthouse run grades 「the easiest device」 instead of 「the worst scenario」.


LCP: The Full Chain from TTFB to Render

LCP is the moment the largest content element (image/video/text block) finishes rendering. It is not a single bottleneck but a chain of four stages — fix the stage that dominates:

Stage Typical share Fixes
① TTFB — CDN, caching, edge rendering
② Resource discovery delay Common <link rel="preload"> for hero image and critical CSS
③ Resource load duration Common Hero image compression, fetchpriority='high', modern formats
④ Render delay Rarer Avoid first-paint JS blocking, font-display: swap

Measured: production TTFB for four pages of this site (median of 3 runs each) —

curl -s -o /dev/null -w '%{time_starttransfer}\n' https://oltool.net/json-formatter.html
# median of 3 runs → 0.075s
Page TTFB (median) Total
/ (home) 0.064 s 0.135 s
/json-formatter.html 0.075 s 0.105 s
/blog/json-parse-errors.html 0.073 s 0.084 s
/collections/convert-excel-to-csv.html 0.083 s 0.096 s

TTFB sits at 64–83 ms (EdgeOne edge node) — SSR first byte is far below the 800 ms warning line ⇒ the LCP bottleneck is not the server; the remaining budget goes to the hero image and fonts.


INP: Not Just 「Make Clicks Fast」

INP tracks all interactions (click/keyboard/touch) across the page lifetime, from input to the next paint, taking a near-worst value. Interaction delay decomposes as:

input → [event handler JS] → [style/layout/paint] → next frame
        ↑ main source of delay            ↑ long tasks also drag this

Ranked causes:

  1. Long tasks (synchronous JS >50 ms) block the main thread, queuing the 「next interaction」;
  2. Handlers that do too much: one click synchronously computing, updating DOM and calling third parties;
  3. Forced synchronous layout: interleaved reads (offsetHeight) and writes trigger layout repeatedly;
  4. Large DOM: more nodes, slower style recalculation.

Three fixes:

  • Split long tasks: yield the main thread with scheduler.yield() / setTimeout for non-urgent work;
  • Immediate feedback: optimistic UI first (highlight/expand instantly), heavy work after requestAnimationFrame or in a Web Worker;
  • Trim third-party scripts: analytics/chat/ads are long tasks you do not control — load on demand.

CLS: Three Sources of Unexpected Shifts

CLS accumulates layout-shift scores within windows without user input. The three most common sources, by fix ROI:

  1. Unsized images/videos: <img> without width/height (or CSS aspect-ratio) pushes content down on load — a one-line fix with the biggest payoff;
  2. Dynamically injected content: ad slots, cookie banners, infinite scroll pushing content away — reserve fixed height or min-height;
  3. Web font swap (FOUT): text reflows when the font loads — mitigate with size-adjust / font-display: optional.

Animate with transform (no layout) instead of top/left (layout) — also the direction to check when 「CLS = 0 but animations feel janky」: smoothness is a frame-rate problem, belonging to INP's response chain.


Reproducible Results

Production TTFB (median of 3 runs each, direct connection):

for i in 1 2 3; do
  curl -s -o /dev/null -w '%{time_starttransfer}\n' https://oltool.net/json-formatter.html
done | sort -n | sed -n 2p
# → 0.075

Performance API full chain (paste into DevTools Console on any page):

const t = performance.getEntriesByType('navigation')[0];
const fcp = performance.getEntriesByType('paint').at(-1)?.startTime ?? 0;
console.log({
  ttfb: Math.round(t.responseStart - t.startTime),
  fcp: Math.round(fcp),
  domContentLoaded: Math.round(t.domContentLoadedEventEnd - t.startTime),
  load: Math.round(t.loadEventEnd - t.startTime),
});
// This site, production: { ttfb: 64, … } (TTFB far under the 800ms warning line)

CLS/INP need the web-vitals library (Performance API does not expose INP):

import { onLCP, onINP, onCLS } from 'web-vitals';
onLCP(console.log); onINP(console.log); onCLS(console.log);
// report to your analytics endpoint; aggregate to P75 for CrUX-comparable data

The Optimization Checklist (Ranked by ROI)

# Action Improves Cost
1 Add width/height or aspect-ratio to images CLS One line per image
2 fetchpriority='high' + preload for the hero image LCP One line
3 defer / lazy-load non-critical JS INP / LCP Low
4 font-display: swap + font preloading LCP / CLS Low
5 Split long tasks with scheduler.yield() INP Medium
6 TTFB: CDN / caching / edge rendering LCP Architecture dependent
7 Convert images to WebP/AVIF and compress LCP Medium

Takeaways

  • Measure before optimizing: field data locates impact, lab data locates causes — reversed, you optimize air;
  • Three metrics, three models: LCP follows the resource chain, INP follows the main thread, CLS follows layout reservation;
  • P75 is the real standard: averages and medians both hide the bad-user experience;
  • This site's production baseline: TTFB 64–83 ms, page totals <150 ms (edge rendering + CDN) — a healthy baseline, with remaining headroom in front-end assets.

The systematic optimization panorama is in the web performance guide; the measurement commands above can be re-run against any site.

Advertisement

Frequently Asked Questions

Lighthouse scores 100, but real users still complain — why?

Lighthouse is **lab data**: a fixed device, network and location, single sample. Real users' INP/LCP come from **CrUX field data** — weaker devices, messier networks, larger samples. The two measure different things: lab data reproduces problems, field data confirms their real-world distribution. Optimize for the P75 (worst quartile) of field data.

TTFB is fast, but LCP is still slow — where is the problem?

TTFB only covers 「request sent to first byte」; LCP is 「largest content element rendered」. In between: HTML download and parsing, discovery and loading of critical resources (CSS/fonts/hero image), and render queuing. Common bottlenecks: the hero image lacks `fetchpriority='high'`, font loading blocks, or CSS is oversized. The step-by-step breakdown is in the article.

CLS is 0, yet the page still jumps — why?

CLS only counts layout shifts **within 1500ms windows without user input**, and animations (transforms) do not count. If a jump happens inside a post-input window (e.g. after a click), it does not count toward CLS but users still feel it. Conversely, CLS = 0 only means 「no unexpected shifts」 — animation jank is a frame-rate problem, covered by INP responsiveness instead.

INP replaced FID — is my FID data still useful?

No — FID was replaced by INP in March 2024. They measure differently: FID only recorded the **first** interaction's input delay, while INP tracks the near-worst interaction across the **entire page lifecycle**. Good FID does not imply good INP — later interactions are often slower than the first. Migrate by reporting INP with the web-vitals library and splitting post-interaction work into async tasks.

← Back to Blog