Core Web Vitals & INP: A Measurement-and-Remediation Playbook for Production
Field data beats lab data — a principal-engineer playbook for measuring LCP, INP, and CLS in production and fixing what CrUX actually scores.
Core Web Vitals (CWV) là ba metric Google dùng để đo trải nghiệm trang của user thật trên Chrome. Chúng không thay thế chất lượng sản phẩm, nhưng là metric duy nhất đi vào tín hiệu Page Experience trên Search và dashboard CrUX mà leadership đọc. Bài này là playbook đo lường và khắc phục cho senior engineer: CWV được định nghĩa thế nào, vì sao field data lệch lab, và cách sửa từng metric mà không áp dụng mù quáng gợi ý Lighthouse.
Phạm vi: Bài này chỉ cover đo lường và khắc phục CWV. Chiến lược cache, tối ưu CSS chi tiết, skeleton screen, và font loading có bài riêng — ta chỉ nhắc khi giao với một vital cụ thể.
Ba Core Web Vitals năm 2026
Từ tháng 3/2024, Interaction to Next Paint (INP) thay First Input Delay (FID) làm vital về độ phản hồi. FID chỉ đo delay của input đầu tiên khi load trang; INP đo latency tương tác tệ nhất trong cả phiên truy cập. Thay đổi thiết kế đó khiến INP khó hơn nhiều — và trung thực hơn với SPA và session dài.
| Metric | What it measures | Good | Needs improvement | Poor |
|---|---|---|---|---|
| LCP (Largest Contentful Paint) | Time until the largest above-the-fold content element is painted | ≤ 2.5 s | 2.5 – 4.0 s | > 4.0 s |
| INP (Interaction to Next Paint) | Worst interaction latency (input → next frame) during the visit | ≤ 200 ms | 200 – 500 ms | > 500 ms |
| CLS (Cumulative Layout Shift) | Sum of unexpected layout shift scores without recent user input | ≤ 0.1 | 0.1 – 0.25 | > 0.25 |
Cả ba ngồi trên percentile 75 (p75) của session user thật trong cửa sổ 28 ngày lăn. Đạt hai trong ba không được tính pass — Google đánh giá từng vital độc lập cho Page Experience.
Page load timeline Interaction timeline
───────────────── ────────────────────
[TTFB][Resource][Render]──► LCP [Input][Process][Paint]──► INP
───────────────── ────────────────────
Visual stability (entire visit) ──────────────────────────────► CLS
Lab vs field: vì sao điểm Lighthouse lừa bạn
Lab data từ test tổng hợp: Lighthouse, WebPageTest, hoặc CI trên phần cứng và profile mạng kiểm soát. Field data (Real User Monitoring, RUM) từ session thật ngoài đời. Dataset field công khai của Google là Chrome User Experience Report (CrUX) — metric gom, ẩn danh từ user Chrome opt-in.
| Dimension | Lab | Field (CrUX / RUM) |
|---|---|---|
| Network | Throttled preset (e.g. Slow 4G) | User’s actual connection — 3G, Wi-Fi, corporate VPN |
| Device | Emulated Moto G4 or MacBook Pro | Long tail of Android mid-range, old iPads, budget laptops |
| Cache state | Usually cold cache | Warm cache, bfcache restores, Service Worker hits |
| Interactions | None (LCP/CLS only unless scripted) | Real taps, typing, rage clicks, multi-step flows |
| Aggregation | Single run | p75 over millions of sessions |
Chọn p75 là có chủ ý. Median (p50) che latency đuôi làm một phần tư user khó chịu. p95 quá nhiễu cho tín hiệu xếp hạng ổn định. p75 là điểm cân: “đa số user trải nghiệm tốt, nhưng vẫn phạt đau hệ thống ở đuôi”.
Lab lừa theo cách dự đoán được:
- CDN ấm, user lạnh: CI chạy từ data center cách origin 5 ms; user Đông Nam Á qua last mile nghẽn.
- Không auth, không cá nhân hóa: Lab vào trang marketing tĩnh; production serve bootstrap JSON 400 KB cho dashboard đã login.
- Third-party không biến thiên: Lab chặn ads; production inject 2 MB script tag-manager sau consent.
- FID ≠ INP: Lighthouse vẫn báo TBT và đôi khi proxy FID; không dự đoán INP trên app nhiều tương tác.
Quy tắc principal engineer: Coi lab là hàng rào regression trong CI; coi field là nguồn sự thật cho ưu tiên và báo cáo lên leadership.
LCP deep dive: tìm element, sửa pipeline
LCP báo thời điểm render element contentful lớn nhất nhìn thấy trong viewport lúc load ban đầu. Ứng viên gồm <img>, poster <video>, phần tử block có background image, và text node trong container block. SVG và <canvas> không là ứng viên LCP theo spec hiện tại.
Element LCP có thể đổi khi load — observer báo nhiều entry, entry cuối trước khi user tương tác hoặc visibilitychange sang hidden mới thắng. Hero image load muộn có thể cướp LCP từ headline đã paint trước.
LCP sub-parts (breakdown quan trọng)
Chrome expose bốn sub-part qua bản attribution của web-vitals và Performance API:
| Sub-part | Definition | Typical root cause |
|---|---|---|
| Time to First Byte (TTFB) | Navigation start → first response byte | Slow origin, cold serverless, missing CDN, heavy SSR |
| Resource load delay | TTFB → resource fetch start | Low fetchpriority, discovery late in HTML, JS blocking parser |
| Resource load duration | Fetch start → last byte | Large unoptimized image, slow CDN POP, HTTP/1.1 head-of-line |
| Element render delay | Last byte → paint | Main-thread long tasks, render-blocking CSS, font blocking |
Sửa từng sub-part độc lập — tối ưu image vô ích nếu TTFB 1.8 giây.
Khắc phục theo sub-part
TTFB: Đưa shell tĩnh ra edge (SSG, ISR, edge SSR). Giữ xử lý server HTML trên critical path dưới 200 ms. Dùng early hints (103) và connection warming cho SSR nặng API nếu không pre-render được.
Resource load delay: Đặt resource LCP trong chunk HTML đầu — không sau client router hay consent gate. Dùng fetchpriority="high" trên <img> LCP và tránh lazy-load nó.
<!-- LCP image: discover early, fetch with high priority -->
<link rel="preload" as="image" href="/hero.avif" fetchpriority="high" />
<img
src="/hero.avif"
width="1200"
height="630"
fetchpriority="high"
decoding="async"
alt="Product dashboard"
/>
Resource load duration: Serve AVIF/WebP với srcset responsive. Size image đúng kích thước hiển thị — nguồn 4000 px trên slot 400 px lãng phí byte và thời gian decode. Đặt image LCP trên cùng hostname CDN để tái sử dụng connection.
Element render delay: Inline critical CSS cho above-the-fold; defer phần còn lại. Chia nhỏ JS đồng bộ trên main thread (xem phần INP). Với framework SSR, dùng streaming để browser parse HTML và bắt đầu fetch resource LCP trước khi response đầy đủ tới.
// Astro / React SSR streaming pattern (conceptual)
const stream = await renderToReadableStream(<App />);
return new Response(stream, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
Failure mode LCP principal gặp trên production
- Carousel hero: Image slide 2 thành LCP sau autoplay — tối ưu slide 1 vẫn fail.
- LCP client-render: React hydrate trước khi
<img>hero có trong DOM — LCP dời sang skeleton hoặc text footer. - Soft navigation: Đổi route SPA không reset LCP cho CrUX (LCP gắn navigation), nhưng phá cảm giác nhanh — đừng nhầm hai thứ.
INP deep dive: chuyển FID → INP và thứ thực sự làm hỏng
FID đo (processingStart - startTime) chỉ cho lần keydown, mousedown, pointerdown, hoặc touchstart đầu tiên. Nó bỏ qua mọi thứ sau hydrate trên dashboard nặng — đúng chỗ user cảm thấy đau.
INP ghi mọi tương tác đủ điều kiện trong lifecycle trang và báo chậm nhất (hoặc percentile 98 ở một số tool — CrUX dùng tương tác tệ nhất mỗi visit). Tương tác đủ điều kiện là click, tap, và phím kích handler — không gồm scroll hay hover thụ động.
INP sub-parts
Mỗi latency tương tác tách thành ba phase:
| Phase | What happens | Fix lever |
|---|---|---|
| Input delay | Main thread busy — input waits in queue | Shorter tasks, scheduler.yield(), defer non-urgent work |
| Processing duration | Your event handlers + framework reconciliation run | Debounce vs yield, Web Workers, startTransition |
| Presentation delay | Style recalc, layout, paint, composite until next frame | Reduce DOM size, avoid layout thrashing, simplify selectors |
User tap
│
▼
[Input delay]──────► main thread blocked by 120ms long task
│
▼
[Processing]───────► onClick → setState → 1000-node reconcile
│
▼
[Presentation]─────► layout + paint blocked by remaining work
│
▼
Next frame visible to user ◄── INP measures up to here
Long task: kẻ thù của INP
Mọi task main thread vượt 50 ms là long task và có thể trễ xử lý input. Một long task 200 ms lúc click cộng tới 200 ms input delay trước khi handler chạy.
// Detect long tasks in production
const longTaskObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
report({
type: 'longtask',
duration: entry.duration,
startTime: entry.startTime,
// attribution available on Long Task Timing Level 2 in supporting browsers
attribution: entry.attribution,
});
}
}
});
longTaskObserver.observe({ type: 'longtask', buffered: true });
Chrome chuyển sang quan sát Long Animation Frame (LoAF), gán URL script và invoker — dùng khi có để phân tích root cause nhanh hơn.
Chia nhỏ work: yield, idle, và scheduler
scheduler.yield() (Scheduler API) chủ động nhường cho browser để input đang chờ chạy. Dùng trong vòng lặp hoặc work client nhiều bước sẽ chiếm main thread.
async function processLargeList(items) {
for (const item of items) {
processItem(item);
// Yield every iteration so clicks/taps are not starved
if ('scheduler' in globalThis && 'yield' in scheduler) {
await scheduler.yield();
}
}
}
isInputPending() (Experimental — kiểm tra support trước khi ship) gợi ý browser có input đang chờ. Ghép với yield trong idle callback để ưu tiên phản hồi hơn work nền.
function runDeferredWork(deadline) {
while (deadline.timeRemaining() > 0 && workQueue.length) {
if (navigator.scheduling?.isInputPending?.()) {
// Stop — user is trying to interact
scheduleIdleContinuation();
return;
}
workQueue.shift()();
}
if (workQueue.length) scheduleIdleContinuation();
}
function scheduleIdleContinuation() {
requestIdleCallback(runDeferredWork, { timeout: 2000 });
}
Debounce vs yield — giải hai bài toán khác nhau. Debounce gom 50 keystroke thành một lần gọi handler — tốt cho autocomplete, tệ nếu UI đơ đến hết cửa sổ debounce. Yield giữ mỗi keystroke phản hồi trong khi trải work đắt qua nhiều frame. Với INP, ưu tiên yield + startTransition hơn debounce mạnh trên control tương tác.
Pattern INP riêng React
Tính năng Concurrent React 18+ map thẳng vào INP sub-parts:
startTransition: Đánh dấu cập nhật state không khẩn — React có thể ngắt cho input.useDeferredValue: Hoãn re-render đắt do input đổi nhanh.- Selective hydration (cấp framework): Hydrate island tương tác trước nội dung inert.
import { startTransition, useState, useDeferredValue } from 'react';
function FilterableList({ items }: { items: Item[] }) {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const filtered = filterItems(items, deferredQuery);
return (
<>
<input
value={query}
onChange={(e) => {
// Urgent: keep input snappy
setQuery(e.target.value);
}}
/>
{/* Non-urgent: large list re-filter deferred */}
<List items={filtered} />
</>
);
}
function TabPanel() {
const [tab, setTab] = useState('overview');
return (
<nav>
{TABS.map((t) => (
<button
key={t.id}
onClick={() => {
startTransition(() => setTab(t.id));
}}
>
{t.label}
</button>
))}
</nav>
);
}
Failure mode: bọc mọi thứ trong startTransition — UI khẩn (toast, focus ring, validate form) chậm vì React hạ ưu tiên quá mức. Chỉ defer update gây commit subtree lớn.
INP trên trang không có tương tác
Nếu trang không có tương tác đủ điều kiện trong visit, INP không được báo — CrUX bỏ qua thay vì chấm zero. Bài chỉ đọc thường pass INP mặc định; app shell fail vì mỗi click đều quan trọng. Đừng bỏ qua INP trên site marketing có widget tương tác (máy tính, banner consent, chat).
CLS deep dive: ổn định là hợp đồng với layout
CLS cộng điểm layout shift bất ngờ cho shift không có hadRecentInput trong 500 ms. Mỗi điểm shift là impact fraction × distance fraction — dịch phần tử lớn một chút có thể tệ hơn phần tử nhỏ dịch xa.
Nguồn CLS phổ biến
| Source | Mechanism | Fix |
|---|---|---|
| Images without dimensions | Browser reserves 0×0 until load | width/height or aspect-ratio |
| Web fonts (FOUT/FOIT) | Fallback → webfont swap changes metrics | size-adjust, subset, optional display |
| Injected content | Banners, toasts, ads push content down | Reserve space with min-height or slot |
| iframe/embeds | Late-loading embed expands container | Aspect-ratio box, skeleton slot |
| Animations | top/left/height animate layout | Animate transform and opacity only |
| SPA route transitions | New view mounts with different height | Consistent shell, cross-fade in fixed container |
/* Reserve space before image loads */
.hero {
aspect-ratio: 1200 / 630;
width: 100%;
max-width: 1200px;
}
.hero img {
width: 100%;
height: 100%;
object-fit: cover;
}
CLS do font mà không lặp lại bài font loading
Khi webfont swap vào và text reflow, CLS tăng vọt. size-adjust trên @font-face chỉnh metric fallback khớp advance width webfont. Tool như Fallback Font Generator tính giá trị; xác minh trong vùng Layout Shift DevTools.
@font-face {
font-family: 'Inter-fallback';
src: local('Arial');
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
bfcache và đo CLS
Khi trang restore từ back/forward cache (bfcache), layout đã ổn — CLS navigation gốc không cộng lại. Tuy nhiên shift mới sau restore (vd ads inject lại) vẫn tính vào CLS session đó. Tránh listener unload và Cache-Control: no-store trên document cần bfcache — thường giết bfcache và làm back chậm cảm giác.
// Check bfcache eligibility failures in DevTools or via PerformanceNavigationTiming
const nav = performance.getEntriesByType('navigation')[0];
if (nav.notRestoredReasons) {
console.table(nav.notRestoredReasons.reasons);
}
Đo production: web-vitals, attribution, và PerformanceObserver
Đừng tự viết công thức Web Vitals trừ khi bạn thích edge case spec. Thư viện web-vitals implement thuật toán chuẩn, xử lý visibility change, khớp methodology CrUX.
Wiring RUM cơ bản
import { onCLS, onINP, onLCP } from 'web-vitals';
type VitalPayload = {
name: string;
value: number;
id: string;
rating: 'good' | 'needs-improvement' | 'poor';
delta: number;
navigationType: string;
};
function sendToAnalytics(metric: VitalPayload) {
const body = JSON.stringify({
...metric,
page: location.pathname,
ts: Date.now(),
});
// sendBeacon survives page unload; fetch keepalive as fallback
if (navigator.sendBeacon?.('/api/vitals', body)) return;
fetch('/api/vitals', {
body,
method: 'POST',
keepalive: true,
headers: { 'Content-Type': 'application/json' },
});
}
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
Báo cáo khi visibilitychange sang hidden — không beforeunload (bị chặn trên nhiều mobile browser, phá bfcache). Thư viện finalize metric tại thời điểm đó.
Bản attribution: data debug bạn thực sự cần
Import từ web-vitals/attribution để có breakdown sub-part trong cùng callback:
import { onINP, onLCP } from 'web-vitals/attribution';
onLCP((metric) => {
const { element, url, timeToFirstByte, resourceLoadDelay, resourceLoadDuration, elementRenderDelay } =
metric.attribution;
sendToAnalytics({
...metric,
lcpElement: element?.tagName,
lcpUrl: url,
ttfb: timeToFirstByte,
loadDelay: resourceLoadDelay,
loadDuration: resourceLoadDuration,
renderDelay: elementRenderDelay,
});
});
onINP((metric) => {
const { interactionTarget, interactionType, inputDelay, processingDuration, presentationDelay } =
metric.attribution;
sendToAnalytics({
...metric,
target: interactionTarget,
type: interactionType,
inputDelay,
processingDuration,
presentationDelay,
});
});
Đưa field attribution vào warehouse analytics — gom theo lcpUrl và interactionTarget để tìm 3 element gây 80% đau. Đây là cách principal engineer tránh “LCP lab giảm 200 ms nhưng CrUX không động”.
PerformanceObserver thuần khi cần thêm
Thư viện web-vitals dùng PerformanceObserver bên trong; hạ xuống khi cần entry type tuỳ chỉnh:
function observe<T extends PerformanceEntry>(
type: string,
callback: (entries: T[]) => void,
): PerformanceObserver | null {
try {
const observer = new PerformanceObserver((list) => {
callback(list.getEntries() as T[]);
});
observer.observe({ type, buffered: true });
return observer;
} catch {
return null;
}
}
// INP uses Event Timing — observe 'event' entries with duration
observe<PerformanceEventTiming>('event', (entries) => {
for (const entry of entries) {
if (entry.duration > 104) {
// 104ms ≈ one frame at 60Hz + processing budget
console.warn('Slow interaction', entry.name, entry.duration, entry.target);
}
}
});
// Layout shifts with sources (CLS debugging)
observe<LayoutShift>('layout-shift', (entries) => {
for (const entry of entries) {
if (entry.hadRecentInput) continue;
for (const source of entry.sources ?? []) {
console.log('Shift', entry.value, source.node, source.previousRect, source.currentRect);
}
}
});
Luôn truyền buffered: true — nếu không sẽ miss entry fire trước khi script observer chạy.
Sampling và privacy
RUM đầy đủ mọi session tốn kém khi scale. Sample 1–10% session cho attribution đầy đủ; luôn thu aggregate LCP/CLS/INP. Gỡ PII khỏi selector interactionTarget trước khi log — button#checkout ổn; input[name=ssn] thì không. Tuân consent framework: đừng gửi vitals sang analytics bên thứ ba trước consent nếu luật yêu cầu.
Framework ưu tiên cho principal engineer
Khi leadership bảo “sửa Core Web Vitals,” mở Lighthouse sửa điểm đỏ là bước sai. Bước đúng là chương trình có cấu trúc gắn field data.
Phase 0: Thiết lập sự thật (tuần 1)
- Lấy data CrUX cho origin trên PageSpeed Insights và CrUX Dashboard.
- Deploy
web-vitals/attributionlên RUM với chiều page và device. - Xác định vital nào fail ở p75 và trên template URL nào.
- So CrUX mobile vs desktop — mobile thường kéo điểm fail.
Phase 1: Quick win ROI rõ (tuần 2–3)
| If failing… | First interventions |
|---|---|
| LCP | Preload + fetchpriority="high" on LCP image; fix TTFB if > 800 ms; remove lazy-load from above-fold hero |
| INP | Profile longest LoAF/long tasks on top 3 interaction targets; add startTransition to tab/filter handlers |
| CLS | Add width/height or aspect-ratio to all above-fold media; reserve slot for consent/ad banners |
Xác nhận trên field, không lab — ship sau flag nếu cần và so p75 28 ngày.
Phase 2: Sửa cấu trúc (tuần 4–8)
- LCP: Streaming SSR, render edge, CDN image tự động format.
- INP: Code-split JS theo route; chuyển tính toán nặng sang Web Worker; audit script bên thứ ba qua LoAF attribution.
- CLS: Primitive design system có hợp đồng kích thước intrinsic; component slot ad có aspect ratio cố định.
Phase 3: Quản trị (liên tục)
- Budget performance trong CI: LCP > 2.5 s hoặc JS > 200 KB trên route critical fail build.
- Dashboard RUM xu hướng vital, không điểm một lần chạy.
- Review bên thứ ba: mỗi tag mới đánh giá tác động INP trước merge.
- Xử lý sự cố: CrUX tụt thì check tương quan deploy + breakdown attribution trước khi đoán.
Decision tree (simplified)
──────────────────────────
CrUX p75 failing?
│
├─ LCP ──► attribution: TTFB high? → origin/CDN
│ load delay high? → preload/fetchpriority
│ render delay high? → main-thread JS
│
├─ INP ──► attribution: input delay? → long tasks
│ processing? → handler/framework work
│ presentation? → layout/paint cost
│
└─ CLS ──► shift sources in DevTools → fix top 3 nodes
Trade-off principal phải truyền đạt
- Preload mọi thứ cải LCP một trang nhưng tranh bandwidth trang khác — preload chỉ resource LCP đã xác minh mỗi template.
- Code-split mạnh giúp INP nhưng tăng round trip — cân với HTTP/2 multiplex và prefetch theo intent.
- Giữ chỗ cho ads cải CLS nhưng giảm viewability — product phải chọn.
- Tối ưu INP trên React thêm gánh nặng nhận thức (
useTransition, deferred value) — document pattern trong design system.
Checklist trước khi tuyên bố thắng
- CrUX p75 Good cho LCP, INP, CLS trên mobile (chiều khắt khe hơn)
- Attribution RUM đã deploy; URL LCP top và interaction target INP top đã ghi nhận
- Budget lab CI chặn regression nhưng không phải metric thành công
- bfcache không bị phá bởi handler
unloadhayno-storetrên HTML - Script bên thứ ba đã audit bằng LoAF/long-task attribution
- Product, design, eng thống nhất giữ chỗ CLS (ads, embed, UI động)
Core Web Vitals nén trải nghiệm user thành ba con số — không hoàn hảo, nhưng vận hành được. Việc principal engineer không phải đuổi điểm Lighthouse xanh; mà xây vòng đo tìm đau thật, sửa sub-part đòn bẩy cao nhất, và giữ regression ra production bằng budget và RUM. Field data thắng lab data — đo đúng thứ CrUX đo, sửa đúng thứ attribution chỉ ra, rồi ship.