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:
- Long tasks (synchronous JS >50 ms) block the main thread, queuing the 「next interaction」;
- Handlers that do too much: one click synchronously computing, updating DOM and calling third parties;
- Forced synchronous layout: interleaved reads (
offsetHeight) and writes trigger layout repeatedly; - Large DOM: more nodes, slower style recalculation.
Three fixes:
- Split long tasks: yield the main thread with
scheduler.yield()/setTimeoutfor non-urgent work; - Immediate feedback: optimistic UI first (highlight/expand instantly), heavy work after
requestAnimationFrameor 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:
- Unsized images/videos:
<img>withoutwidth/height(or CSSaspect-ratio) pushes content down on load — a one-line fix with the biggest payoff; - Dynamically injected content: ad slots, cookie banners, infinite scroll pushing content away — reserve fixed height or
min-height; - 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.