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) are the three metrics Google uses to quantify real-user page experience in Chrome {Core Web Vitals (CWV) là ba metric Google dùng để đo trải nghiệm trang của user thật trên Chrome}. They are not a substitute for product quality, but they are the only metrics that feed Page Experience signals in Search and the CrUX dashboard your leadership reads {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}. This post is a measurement-and-remediation playbook for senior engineers: how CWV are defined, why field data diverges from lab, and how to fix each metric without cargo-culting Lighthouse suggestions {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}.
Scope {Phạm vi}: This article covers CWV measurement and remediation only {Bài này chỉ cover đo lường và khắc phục CWV}. Caching strategy, CSS micro-optimization, skeleton screens, and font loading have dedicated posts elsewhere — we reference them only where they intersect with a specific vital {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ể}.
The three Core Web Vitals in 2026 {Ba Core Web Vitals năm 2026}
Since March 2024, Interaction to Next Paint (INP) replaced First Input Delay (FID) as the responsiveness vital {Từ tháng 3/2024, Interaction to Next Paint (INP) thay First Input Delay (FID) làm vital về độ phản hồi}. FID measured only the first input delay on a page load; INP measures the worst interaction latency across the entire page visit {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}. That single design change makes INP a much harder metric — and a much more honest one for SPAs and long sessions {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 |
All three thresholds apply at the 75th percentile (p75) of real-user sessions over a rolling 28-day window {Cả ba ngồi trên percentile 75 (p75) của session user thật trong cửa sổ 28 ngày lăn}. Passing two of three is not a pass — Google evaluates each vital independently for the Page Experience assessment {Đạ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: why your Lighthouse score lies {Lab vs field: vì sao điểm Lighthouse lừa bạn}
Lab data comes from synthetic tests: Lighthouse, WebPageTest, or CI runs on controlled hardware and network profiles {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) comes from actual sessions in the wild {Field data (Real User Monitoring, RUM) từ session thật ngoài đời}. Google’s public field dataset is the Chrome User Experience Report (CrUX) — aggregated, anonymized metrics from opted-in Chrome users {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 |
The p75 choice is deliberate {Chọn p75 là có chủ ý}. Median (p50) hides tail latency that frustrates a quarter of your users {Median (p50) che latency đuôi làm một phần tư user khó chịu}. p95 is too noisy for stable ranking signals {p95 quá nhiễu cho tín hiệu xếp hạng ổn định}. p75 is the sweet spot: “most users have a good experience, but we still penalize systemic tail pain” {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 can lie in predictable ways {Lab lừa theo cách dự đoán được}:
- Warm CDN, cold user: Your CI runs from a data center 5 ms from the origin; users in Southeast Asia hit a congested last mile {CDN ấm, user lạnh: CI chạy từ data center cách origin 5 ms; user Đông Nam Á qua last mile nghẽn}.
- No auth, no personalization: Lab hits a static marketing page; production serves a 400 KB JSON bootstrap for logged-in dashboards {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}.
- No third-party variance: Lab blocks ads; production injects 2 MB of tag-manager scripts after consent {Third-party không biến thiên: Lab chặn ads; production inject 2 MB script tag-manager sau consent}.
- FID ≠ INP: Lighthouse still reports TBT and sometimes FID proxies; neither predicts INP on interaction-heavy apps {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}.
Principal-engineer rule {Quy tắc principal engineer}: Treat lab as a regression guard in CI; treat field as the source of truth for prioritization and executive reporting {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: find the element, fix the pipeline {LCP deep dive: tìm element, sửa pipeline}
LCP reports the render time of the largest contentful element visible in the viewport during the initial load {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}. Candidates include <img>, <video> poster, block-level elements with background images, and text nodes in block containers {Ứng viên gồm <img>, poster <video>, phần tử block có background image, và text node trong container block}. SVG and <canvas> are not LCP candidates as of current spec {SVG và <canvas> không là ứng viên LCP theo spec hiện tại}.
The LCP element can change during load — the observer reports multiple entries, and the last one before user interaction or visibilitychange to hidden wins {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}. A hero image that loads late can steal LCP from a headline that painted first {Hero image load muộn có thể cướp LCP từ headline đã paint trước}.
LCP sub-parts (the breakdown that matters) {LCP sub-parts (breakdown quan trọng)}
Chrome exposes four sub-parts via the web-vitals attribution build and Performance API {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 |
Fix each sub-part independently — optimizing images does nothing if TTFB is 1.8 s {Sửa từng sub-part độc lập — tối ưu image vô ích nếu TTFB 1.8 giây}.
Remediation by sub-part {Khắc phục theo sub-part}
TTFB {TTFB}: Move static shells to the edge (SSG, ISR, edge SSR) {Đưa shell tĩnh ra edge (SSG, ISR, edge SSR)}. Keep server work under 200 ms for HTML documents on the critical path {Giữ xử lý server HTML trên critical path dưới 200 ms}. Use early hints (103) and connection warming for API-heavy SSR if you cannot pre-render {Dùng early hints (103) và connection warming cho SSR nặng API nếu không pre-render được}.
Resource load delay {Resource load delay}: Put the LCP resource in the first HTML chunk — not behind a client-side router or consent gate {Đặt resource LCP trong chunk HTML đầu — không sau client router hay consent gate}. Use fetchpriority="high" on the LCP <img> and avoid lazy-loading it {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 {Resource load duration}: Serve AVIF/WebP with responsive srcset {Serve AVIF/WebP với srcset responsive}. Size images to displayed dimensions — a 4000 px source on a 400 px slot wastes bytes and decode time {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}. Put LCP images on the same CDN hostname to reuse connections {Đặt image LCP trên cùng hostname CDN để tái sử dụng connection}.
Element render delay {Element render delay}: Inline critical CSS for above-the-fold content; defer everything else {Inline critical CSS cho above-the-fold; defer phần còn lại}. Break up synchronous JS on the main thread (see INP section) {Chia nhỏ JS đồng bộ trên main thread (xem phần INP)}. For SSR frameworks, use streaming so the browser can parse HTML and start fetching LCP resources before the full response arrives {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' },
});
LCP failure modes principals see in production {Failure mode LCP principal gặp trên production}
- Carousel hero: Slide 2’s image becomes LCP after autoplay — optimize slide 1 only and you still fail {Carousel hero: Image slide 2 thành LCP sau autoplay — tối ưu slide 1 vẫn fail}.
- Client-rendered LCP: React hydrates before the hero
<img>exists in DOM — LCP shifts to a skeleton or footer text {LCP client-render: React hydrate trước khi<img>hero có trong DOM — LCP dời sang skeleton hoặc text footer}. - Soft navigations: SPA route changes do not reset LCP for CrUX (LCP is navigation-bound), but they destroy perceived performance — do not conflate the two {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: the FID → INP transition and what actually breaks {INP deep dive: chuyển FID → INP và thứ thực sự làm hỏng}
FID measured (processingStart - startTime) for the first keydown, mousedown, pointerdown, or touchstart only {FID đo (processingStart - startTime) chỉ cho lần keydown, mousedown, pointerdown, hoặc touchstart đầu tiên}. It ignored everything after hydration on a heavy dashboard — exactly where users feel pain {Nó bỏ qua mọi thứ sau hydrate trên dashboard nặng — đúng chỗ user cảm thấy đau}.
INP captures every qualifying interaction during the page lifecycle and reports the slowest (or 98th percentile in some tooling — CrUX uses the worst interaction per visit) {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)}. Qualifying interactions are clicks, taps, and key presses that trigger event handlers — not scroll or passive hover {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 {INP sub-parts}
Each interaction latency decomposes into three phases {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 tasks: the INP enemy {Long task: kẻ thù của INP}
Any task on the main thread exceeding 50 ms is a long task and can delay input processing {Mọi task main thread vượt 50 ms là long task và có thể trễ xử lý input}. One 200 ms task during a click adds up to 200 ms of input delay before your handler even runs {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 is migrating toward Long Animation Frame (LoAF) observation, which attributes script URLs and invoker types — use it when available for faster root-cause analysis {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}.
Breaking up work: yield, idle, and the scheduler {Chia nhỏ work: yield, idle, và scheduler}
scheduler.yield() (Scheduler API) explicitly yields to the browser so pending input can run {scheduler.yield() (Scheduler API) chủ động nhường cho browser để input đang chờ chạy}. Use it inside loops or multi-step client work that would otherwise monopolize the main thread {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 — check support before shipping) hints whether the browser has pending input {isInputPending() (Experimental — kiểm tra support trước khi ship) gợi ý browser có input đang chờ}. Pair it with yielding in idle callbacks to prioritize responsiveness over background work {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 });
}
Debouncing vs yielding — they solve different problems {Debounce vs yield — giải hai bài toán khác nhau}. Debouncing collapses 50 keystrokes into one handler call — good for search autocomplete, bad if the UI feels frozen until the debounce window closes {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}. Yielding keeps every keystroke responsive while spreading expensive work across frames {Yield giữ mỗi keystroke phản hồi trong khi trải work đắt qua nhiều frame}. For INP, prefer yield + startTransition over aggressive debounce on interactive controls {Với INP, ưu tiên yield + startTransition hơn debounce mạnh trên control tương tác}.
React-specific INP patterns {Pattern INP riêng React}
React 18+ Concurrent features map directly to INP sub-parts {Tính năng Concurrent React 18+ map thẳng vào INP sub-parts}:
startTransition: Marks state updates as non-urgent — React can interrupt them for input {startTransition: Đánh dấu cập nhật state không khẩn — React có thể ngắt cho input}.useDeferredValue: Defers expensive re-renders driven by fast-changing input {useDeferredValue: Hoãn re-render đắt do input đổi nhanh}.- Selective hydration (framework-level): Hydrate interactive islands before inert content {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: wrapping everything in startTransition — urgent UI (toasts, focus rings, form validation) becomes sluggish because React deprioritizes it too {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}. Only defer updates that cause large subtree commits {Chỉ defer update gây commit subtree lớn}.
INP on pages with no interactions {INP trên trang không có tương tác}
If a page has no qualifying interactions during a visit, INP is not reported for that session — CrUX omits it rather than scoring zero {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}. Content-only articles often pass INP by default; app shells fail because every click matters {Bài chỉ đọc thường pass INP mặc định; app shell fail vì mỗi click đều quan trọng}. Do not ignore INP on marketing sites with interactive widgets (calculators, consent banners, chat widgets) {Đừng bỏ qua INP trên site marketing có widget tương tác (máy tính, banner consent, chat)}.
CLS deep dive: stability is a contract with the layout {CLS deep dive: ổn định là hợp đồng với layout}
CLS sums unexpected layout shift scores for shifts without hadRecentInput within 500 ms {CLS cộng điểm layout shift bất ngờ cho shift không có hadRecentInput trong 500 ms}. Each shift score is impact fraction × distance fraction — moving a large element a small distance can score worse than moving a tiny element far {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}.
Common CLS sources {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;
}
Font-related CLS without re-litigating font loading {CLS do font mà không lặp lại bài font loading}
When a webfont swaps in and text reflows, CLS spikes {Khi webfont swap vào và text reflow, CLS tăng vọt}. size-adjust on @font-face nudges fallback metrics to match the webfont’s advance widths {size-adjust trên @font-face chỉnh metric fallback khớp advance width webfont}. Tools like Fallback Font Generator compute values; verify in DevTools Layout Shift regions {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 and CLS measurement {bfcache và đo CLS}
When a page restores from back/forward cache (bfcache), layout is already settled — CLS from the original navigation does not re-accumulate {Khi trang restore từ back/forward cache (bfcache), layout đã ổn — CLS navigation gốc không cộng lại}. However, new shifts after restore (e.g. re-injected ads) count toward that session’s CLS {Tuy nhiên shift mới sau restore (vd ads inject lại) vẫn tính vào CLS session đó}. Avoid unload listeners and Cache-Control: no-store on documents you want bfcache-eligible — they are common bfcache killers that also hurt perceived back-navigation speed {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);
}
Measuring in production: web-vitals, attribution, and PerformanceObserver {Đo production: web-vitals, attribution, và PerformanceObserver}
Do not hand-roll Web Vitals math unless you enjoy spec edge cases {Đừng tự viết công thức Web Vitals trừ khi bạn thích edge case spec}. The web-vitals library implements the canonical algorithms, handles visibility changes, and matches CrUX methodology {Thư viện web-vitals implement thuật toán chuẩn, xử lý visibility change, khớp methodology CrUX}.
Basic RUM wiring {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);
Report on visibilitychange to hidden — not beforeunload (blocked on many mobile browsers, breaks bfcache) {Báo cáo khi visibilitychange sang hidden — không beforeunload (bị chặn trên nhiều mobile browser, phá bfcache)}. The library finalizes metrics at that point {Thư viện finalize metric tại thời điểm đó}.
Attribution build: the debug data you actually need {Bản attribution: data debug bạn thực sự cần}
Import from web-vitals/attribution to get sub-part breakdowns in the same callback {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,
});
});
Ship attribution fields to your analytics warehouse — aggregate by lcpUrl and interactionTarget to find the 3 elements causing 80% of pain {Đưa field attribution vào warehouse analytics — gom theo lcpUrl và interactionTarget để tìm 3 element gây 80% đau}. This is how principal engineers avoid “we improved LCP by 200 ms globally but CrUX didn’t move” {Đây là cách principal engineer tránh “LCP lab giảm 200 ms nhưng CrUX không động”}.
Raw PerformanceObserver when you need more {PerformanceObserver thuần khi cần thêm}
The web-vitals library uses PerformanceObserver internally; drop down when you need custom entry types {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);
}
}
});
Always pass buffered: true — otherwise you miss entries that fired before your observer script executed {Luôn truyền buffered: true — nếu không sẽ miss entry fire trước khi script observer chạy}.
Sampling and privacy {Sampling và privacy}
Full RUM on every session is expensive at scale {RUM đầy đủ mọi session tốn kém khi scale}. Sample 1–10% of sessions for full attribution; always collect LCP/CLS/INP aggregates {Sample 1–10% session cho attribution đầy đủ; luôn thu aggregate LCP/CLS/INP}. Strip PII from interactionTarget selectors before logging — button#checkout is fine; input[name=ssn] is not {Gỡ PII khỏi selector interactionTarget trước khi log — button#checkout ổn; input[name=ssn] thì không}. Respect consent frameworks: do not send vitals to third-party analytics before consent where legally required {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}.
A prioritization framework for principal engineers {Framework ưu tiên cho principal engineer}
When leadership says “fix Core Web Vitals,” the wrong move is opening Lighthouse and fixing whatever is red {Khi leadership bảo “sửa Core Web Vitals,” mở Lighthouse sửa điểm đỏ là bước sai}. The right move is a structured program tied to field data {Bước đúng là chương trình có cấu trúc gắn field data}.
Phase 0: Establish truth (week 1) {Phase 0: Thiết lập sự thật (tuần 1)}
- Pull CrUX data for your origin in PageSpeed Insights and CrUX Dashboard {Lấy data CrUX cho origin trên PageSpeed Insights và CrUX Dashboard}.
- Deploy
web-vitals/attributionto RUM with page-level and device-level dimensions {Deployweb-vitals/attributionlên RUM với chiều page và device}. - Identify which vital fails at p75 and on which URL templates {Xác định vital nào fail ở p75 và trên template URL nào}.
- Compare CrUX mobile vs desktop — mobile usually drives the failing score {So CrUX mobile vs desktop — mobile thường kéo điểm fail}.
Phase 1: Quick wins with proven ROI (weeks 2–3) {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 |
Validate in field, not lab — ship behind a flag if needed and compare p75 over 28 days {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: Structural fixes (weeks 4–8) {Phase 2: Sửa cấu trúc (tuần 4–8)}
- LCP: Streaming SSR, edge rendering, image CDN with automatic format negotiation {LCP: Streaming SSR, render edge, CDN image tự động format}.
- INP: Code-split route-level JS; move heavy computation to Web Workers; audit third-party scripts with LoAF attribution {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: Design-system primitives with intrinsic size contracts; ad slot components with fixed aspect ratios {CLS: Primitive design system có hợp đồng kích thước intrinsic; component slot ad có aspect ratio cố định}.
Phase 3: Governance (ongoing) {Phase 3: Quản trị (liên tục)}
- Performance budgets in CI: LCP > 2.5 s or JS > 200 KB on critical routes fails the build {Budget performance trong CI: LCP > 2.5 s hoặc JS > 200 KB trên route critical fail build}.
- RUM dashboards with vital trends, not single-run scores {Dashboard RUM xu hướng vital, không điểm một lần chạy}.
- Third-party review: every new tag gets an INP impact assessment before merge {Review bên thứ ba: mỗi tag mới đánh giá tác động INP trước merge}.
- Incident response: when CrUX drops, check deploy correlation + attribution breakdown before guessing {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-offs principals must communicate {Trade-off principal phải truyền đạt}
- Preload everything improves LCP on one page but contends bandwidth on others — preload only the verified LCP resource per template {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}.
- Aggressive code-splitting helps INP but increases round trips — balance with HTTP/2 multiplexing and prefetch on intent {Code-split mạnh giúp INP nhưng tăng round trip — cân với HTTP/2 multiplex và prefetch theo intent}.
- Reserving space for ads improves CLS but reduces ad viewability — product must choose {Giữ chỗ cho ads cải CLS nhưng giảm viewability — product phải chọn}.
- INP optimization in React adds mental overhead (
useTransition, deferred values) — document patterns in your design system {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 before you declare victory {Checklist trước khi tuyên bố thắng}
- CrUX p75 Good for LCP, INP, and CLS on mobile (the stricter dimension) {CrUX p75 Good cho LCP, INP, CLS trên mobile (chiều khắt khe hơn)}
- RUM attribution deployed; top LCP URL and top INP interaction target documented {Attribution RUM đã deploy; URL LCP top và interaction target INP top đã ghi nhận}
- Lab CI budget prevents regression but is not the success metric {Budget lab CI chặn regression nhưng không phải metric thành công}
- bfcache not broken by
unloadhandlers orno-storeon HTML {bfcache không bị phá bởi handlerunloadhayno-storetrên HTML} - Third-party scripts audited with LoAF/long-task attribution {Script bên thứ ba đã audit bằng LoAF/long-task attribution}
- Product, design, and eng aligned on CLS reservations (ads, embeds, dynamic UI) {Product, design, eng thống nhất giữ chỗ CLS (ads, embed, UI động)}
Core Web Vitals are a compression of user experience into three numbers — imperfect, but operational {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}. The principal-engineer job is not to chase green Lighthouse scores; it is to build a measurement loop that finds real pain, fix the highest-leverage sub-parts, and keep regressions out of production with budgets and RUM {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 beats lab data — measure what CrUX measures, fix what attribution names, and ship {Field data thắng lab data — đo đúng thứ CrUX đo, sửa đúng thứ attribution chỉ ra, rồi ship}.