Since 2021 Google includes Core Web Vitals in search rankings, making performance a growth lever instead of just UX polish. Three metrics directly capture user perception of load, stability, and responsiveness.
LCP (Largest Contentful Paint) <2.5s
Four LCP factors: slow server response -> CDN/HTTP3/preconnect critical origins. Render-blocking resources -> inline critical CSS / defer the rest. Slow loads -> fetchpriority=high/srcset/AVIF. Client render -> SSR/islands.
import { onCLS, onINP, onLCP, type Metric } from "web-vitals";
const URL = "/api/analytics/vitals";
function send(metric: Metric) {
const body = JSON.stringify({
name: metric.name, value: metric.value, rating: metric.rating,
id: metric.id, url: location.href, ts: Date.now(),
});
if (navigator.sendBeacon) navigator.sendBeacon(URL, body);
else fetch(URL, { body, method: "POST", keepalive: true }).catch(() => {});
}
export function initVitals() {
if (location.hostname === "localhost") {
const log = (m: Metric) => console.log(`[vitals] ${m.name}=${Math.round(m.value)} rating=${m.rating}`);
onLCP(log); onCLS(log); onINP(log); return;
}
onLCP(send); onCLS(send); onINP(send);
}
export function scheduleHigh(work: () => void): void {
if ("scheduler" in window && "postTask" in (window as any).scheduler)
(window as any).scheduler.postTask(work, { priority: "user-blocking" });
else if ("requestIdleCallback" in window)
requestAnimationFrame(() => requestIdleCallback(() => work(), { timeout: 1500 }));
else setTimeout(work, 0);
}
export function splitTasks(total: number, unit: (i: number) => void, chunk = 25): void {
let i = 0;
const pump = (deadline?: any): void => {
const rem = typeof deadline !== "undefined" && deadline.timeRemaining ? deadline : { timeRemaining: () => 8 };
while (i < total && rem.timeRemaining() > 1 && (i % chunk !== 0 || i === 0)) unit(i++);
if (i < total) "requestIdleCallback" in window ? requestIdleCallback(pump) : setTimeout(pump, 0);
};
"requestIdleCallback" in window ? requestIdleCallback(pump) : pump();
}
export function throttle<T extends (...a: any[]) => void>(fn: T, wait = 150, opts: { leading?: boolean; trailing?: boolean } = {}): T {
const { leading = true, trailing = true } = opts;
let last = 0, tId: any = null, lastArgs: any[] | null = null;
return function (this: any, ...args: any[]) {
const now = Date.now();
if (!last && !leading) last = now;
const remaining = wait - (now - last);
lastArgs = args;
if (remaining <= 0) {
if (tId) { clearTimeout(tId); tId = null; }
last = now; fn.apply(this, args); lastArgs = null;
} else if (trailing && !tId) {
tId = setTimeout(() => {
last = leading ? Date.now() : 0; tId = null;
if (lastArgs) { fn.apply(this, lastArgs); lastArgs = null; }
}, remaining);
}
} as T;
}
CLS <0.1 & INP <200ms
CLS culprits: images/videos without explicit dimensions, font swap jitter, unbanners/dynamic inserts without reserved height. INP replaces FID - measures every input-to-paint; >50ms Long Tasks the primary optimization target.
| Metric | Green Target | Top Culprit #1 | Top Culprit #2 | Primary Fixes |
|---|---|---|---|---|
| LCP | <2.5s | Heavy hero images | Slow TTFB | preconnect, AVIF, fetchpriority |
| CLS | <0.1 | Images without w/h | Font swap reflow | Declare dims, font-display:optional |
| INP | <200ms | Long task blocks main | Heavy style/layout | postTask, rIC splitting |
| TTFB | <800ms | Slow backend queries | No edge caching | CDN cache, SSR streaming |
Best Practices
Ship the web-vitals library for Real User Monitoring (RUM). Local Lighthouse lab data isn't enough. Always budget 3-10x headroom because mobile devices are slower by that factor.