jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Web Performance · Part 2 — Core Web Vitals: LCP, INP & CLS

The three metrics Google uses to score experience: LCP (loading), INP (interaction), CLS (layout stability). What each measures exactly, "good" thresholds, the p75 percentile, and measuring with web-vitals.

Core Web Vitals (CWV) là bộ ba chỉ số Google chọn để đại diện cho trải nghiệm người dùng, và dùng làm tín hiệu xếp hạng tìm kiếm. Hiểu chính xác mỗi cái đo gì là điều kiện tiên quyết trước khi tối ưu — vì mỗi chỉ số có nguyên nhân và cách sửa hoàn toàn khác nhau.


1. Ba chỉ số, ba khía cạnh

Chỉ sốĐo khía cạnhCâu hỏi nó trả lờiNgưỡng “Tốt”
LCP (Largest Contentful Paint)Tải“Nội dung chính hiện ra nhanh không?”≤ 2.5s
INP (Interaction to Next Paint)Tương tác“Bấm/gõ có phản hồi nhanh không?”≤ 200ms
CLS (Cumulative Layout Shift)Ổn định“Bố cục có nhảy lung tung không?”≤ 0.1

Ba khía cạnh này gần như độc lập: trang có thể tải nhanh (LCP tốt) nhưng giật khi bấm (INP tệ), hoặc ngược lại. Vì vậy phải đo và tối ưu cả ba.


2. LCP — Largest Contentful Paint

LCP đo thời điểm phần tử nội dung lớn nhất trong viewport được render. “Lớn nhất” thường là ảnh hero, ảnh nền CSS, poster/frame đầu của video, hoặc khối text lớn. Đây là đại diện gần đúng cho “khi nào nội dung chính đã hiện ra”.

Điều quan trọng cần hiểu: LCP không phải khi trang load xong hết, mà là khi phần tử lớn nhất nhìn thấy được xuất hiện. Phần tử LCP có thể đổi trong lúc tải (text lớn xuất hiện trước, rồi ảnh hero tải xong thay thế).

// Measure LCP roughly with PerformanceObserver
new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const last = entries[entries.length - 1]; // latest candidate seen by this callback
  console.log('LCP:', last.startTime.toFixed(0), 'ms', last);
}).observe({ type: 'largest-contentful-paint', buffered: true });

Nguyên nhân LCP tệ thường là: server phản hồi chậm (TTFB), tài nguyên chặn render (CSS/JS), ảnh hero tải chậm, font chặn hiển thị text. Ta tối ưu kỹ ở Phần 5.


3. INP — Interaction to Next Paint

INP (thay thế FID từ tháng 3/2024) đo độ trễ từ khi người dùng tương tác đến khi trình duyệt trình bày khung hình kế tiếp. Nó quan sát click/tap/keyboard trong cả lượt truy cập và thường lấy tương tác chậm nhất; với trang có rất nhiều tương tác, thuật toán bỏ dần một số outlier (một tương tác tệ nhất cho mỗi 50 tương tác). Vì vậy INP phản ánh khả năng đáp ứng tổng thể chứ không chỉ lần đầu.

Một tương tác gồm 3 phần, INP đo cả ba:

user taps

   ├─ Input delay  → main thread busy (long task), can't process yet
   ├─ Processing   → run event handler (your code)
   └─ Presentation → compute layout + paint new frame

   ▼ screen updates  ← INP = total time here
// Inspect slow Event Timing entries; this is not the full INP algorithm.
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    const e = entry as PerformanceEventTiming;
    const duration = e.duration; // input → next paint (rounded for privacy)
    if (duration > 200) console.warn('Slow interaction:', e.name, duration.toFixed(0), 'ms');
  }
}).observe({ type: 'event', buffered: true, durationThreshold: 16 } as PerformanceObserverInit);

Một interaction có thể gồm nhiều event cùng interactionId; đoạn trên chỉ giúp tìm event chậm. Để gộp event, xử lý outlier, bfcache và vòng đời trang đúng như CWV, dùng web-vitals ở mục 6.

Thủ phạm INP chủ yếu là long task chặn main thread (JS chạy quá lâu một mạch), handler nặng, và re-render lớn. Sửa bằng chia nhỏ việc, web worker, tối ưu render — Phần 6.


4. CLS — Cumulative Layout Shift

CLS đo cụm layout shift bất ngờ tệ nhất trong vòng đời trang. Các shift được gom vào “session window”: khoảng cách giữa hai shift dưới 1 giây và mỗi window dài tối đa 5 giây; CLS là tổng điểm lớn nhất trong một window. Cách tính này tránh việc trang mở lâu bị cộng dồn vô hạn.

Điểm shift = tỉ lệ vùng bị ảnh hưởng × khoảng cách dịch chuyển. Công thức không cần thuộc; điều cần nhớ là layout shift do nội dung chèn vào mà không chừa chỗ trước.

// Approximate current CLS session-window logic for learning purposes.
let cls = 0;
let sessionValue = 0;
let sessionStart = 0;
let lastShift = 0;
if (PerformanceObserver.supportedEntryTypes.includes('layout-shift')) {
  new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      const e = entry as LayoutShift;
      if (e.hadRecentInput) continue;

      const startsNewSession =
        sessionValue === 0 ||
        e.startTime - lastShift >= 1000 ||
        e.startTime - sessionStart >= 5000;

      if (startsNewSession) {
        sessionValue = e.value;
        sessionStart = e.startTime;
      } else {
        sessionValue += e.value;
      }

      lastShift = e.startTime;
      cls = Math.max(cls, sessionValue);
    }
    console.log('CLS:', cls.toFixed(3));
  }).observe({ type: 'layout-shift', buffered: true });
}

LayoutShift chưa được expose đồng đều ở mọi browser; đoạn trên để học thuật toán và đã feature-detect. RUM production nên dùng web-vitals, đồng thời chấp nhận rằng độ phủ metric phụ thuộc API mà browser cung cấp.

Nguyên nhân CLS: ảnh/iframe/quảng cáo không khai báo kích thước, nội dung chèn động (banner), font swap làm chữ đổi kích thước (FOUT). Sửa bằng đặt chỗ trước — Phần 7.


5. p75 — vì sao không dùng giá trị trung bình

Google phân loại CWV bằng percentile thứ 75 (p75) của lượt xem trang thực tế, tách giữa mobile và desktop. Nghĩa là: để được xếp loại “tốt” cho một metric, ít nhất 75% lượt xem trong nhóm đó phải đạt ngưỡng tốt.

Trung bình dễ bị vài giá trị cực đoan kéo lệch; median (p50) lại bỏ qua quá nhiều lượt chậm. p75 là điểm cân bằng để đánh giá phần lớn lượt truy cập, nhưng không thay thế p95/p99 khi bạn cần tìm nhóm hiếm đang chịu trải nghiệm rất tệ.

Hệ quả thực tế: đừng chỉ test trên máy xịn. Một trang “p50 nhanh” vẫn có thể trượt CWV ở p75; hãy xem thêm p95 và cắt lát theo thiết bị/mạng trong RUM để không bỏ sót phần đuôi.


6. Đo đúng cách: thư viện web-vitals

Tự viết observer như trên hữu ích để hiểu, nhưng đo CWV chính xác như Google có nhiều tinh tế (bfcache, tab ẩn, gộp tương tác). Thư viện chính chủ web-vitals xử lý hết:

import { onLCP, onINP, onCLS } from 'web-vitals';

// Callbacks can run again when the page is hidden or restored from bfcache.
function sendToAnalytics(metric: {
  name: string;
  value: number;
  rating: string;
  id: string;
  delta: number;
}) {
  const body = JSON.stringify(metric);
  if (!navigator.sendBeacon?.('/analytics', body)) {
    void fetch('/analytics', { method: 'POST', body, keepalive: true });
  }
}

onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);

metric.rating cho luôn 'good' | 'needs-improvement' | 'poor' theo ngưỡng chuẩn. Callback có thể chạy nhiều lần trong một lượt xem; backend nên upsert theo metric.id hoặc lưu delta. Đây là nền tảng để dựng RUM (đo field) ở Phần 3.


7. Các chỉ số bổ trợ (chẩn đoán)

CWV là chỉ số kết quả; để chẩn đoán bạn cần các chỉ số chi tiết:

  • TTFB (Time To First Byte): server phản hồi nhanh không — ảnh hưởng LCP.
  • FCP (First Contentful Paint): pixel đầu tiên xuất hiện — ảnh hưởng cảm nhận.
  • TBT (Total Blocking Time): tổng thời gian main thread bị chặn (lab proxy cho INP).

Khi LCP tệ, nhìn TTFB và FCP để biết nghẽn ở server hay ở render. Khi INP tệ, nhìn TBT và long tasks.


Tóm tắt

  • LCP (≤ 2.5s): khi nội dung lớn nhất hiện ra — đại diện cho “đã tải”.
  • INP (≤ 200ms): độ trễ tương tác (input delay + xử lý + vẽ) — đại diện cho “phản hồi”.
  • CLS (≤ 0.1): mức bố cục nhảy bất ngờ — đại diện cho “ổn định”.
  • Google phân loại theo p75 lượt xem thực tế, tách mobile/desktop; RUM nên theo dõi thêm p95 và các phân khúc yếu.
  • Dùng thư viện web-vitals để đo chuẩn; TTFB/FCP/TBT để chẩn đoán nguyên nhân.

Phần tiếp theo: Bộ công cụ đo — Lighthouse, tab Performance, WebPageTest, CrUX và dựng RUM của riêng bạn.