jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Web Performance · Part 13 — RUM & Field Data Deep Dive

Đo người dùng thật như senior: web-vitals Attribution để phân rã LCP/INP/CLS, lấy mẫu, percentile p75, gửi bằng sendBeacon, và CrUX API/BigQuery.

Phần 3 giới thiệu lab vs field data. Mười phần chuyên sâu này bắt đầu từ thứ quan trọng nhất: đo người dùng thật (Real User Monitoring). Lab data nói trang nhanh trên máy bạn; chỉ RUM nói nó nhanh với người dùng thật trên điện thoại 4G. Phần này dạy cách thu, quy trách (attribution), và phân tích field data đúng cách — nền tảng cho mọi quyết định tối ưu sau đó.

Nguyên tắc: tối ưu cái bạn đo được ở field, không phải lab. Mọi phần sau (14–22) đều nên được kiểm chứng bằng RUM, không phải Lighthouse.


1. Thư viện web-vitals — cách chuẩn để thu

Google duy trì thư viện web-vitals để triển khai định nghĩa Core Web Vitals (cùng TTFB/FCP) trên các API mà trình duyệt cung cấp. Coverage từng metric vẫn phụ thuộc engine; dữ liệu client cũng không nhìn xuyên nội dung iframe như CrUX có thể làm.

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

function sendToAnalytics(metric: { name: string; value: number; id: string; rating: string }) {
  const body = JSON.stringify(metric);
  // Ưu tiên sendBeacon khi trang hidden/unload; false nghĩa là browser không xếp hàng được.
  navigator.sendBeacon?.('/rum', body) ||
    fetch('/rum', { body, method: 'POST', keepalive: true });
}

onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
onTTFB(sendToAnalytics);
onFCP(sendToAnalytics);

Mấu chốt: ứng viên CLS và INP có thể thay đổi suốt vòng đời trang. web-vitals tự gọi callback khi giá trị sẵn sàng báo cáo, bao gồm lúc trang chuyển sang hidden; endpoint phải upsert theo id hoặc cộng delta vì một metric có thể được gửi nhiều lần (và có id mới sau khi khôi phục từ BFCache). Nếu tự gom beacon, flush hàng đợi ở visibilitychange → hidden bằng sendBeacon.


2. Attribution build — thu hẹp nguyên nhân gây chậm

Đo được “INP = 450ms” chưa giúp gì nếu không biết tương tác nào, phần tử nào, vì sao. Bản Attribution của web-vitals trả về dữ liệu chẩn đoán đính kèm; đây là manh mối để tái hiện và profile, không phải lúc nào cũng chỉ thẳng một dòng code duy nhất.

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

onINP((metric) => {
  const a = metric.attribution;
  send({
    value: metric.value,
    target: a.interactionTarget,     // selector của phần tử bị tương tác
    type: a.interactionType,         // 'pointer' | 'keyboard'
    inputDelay: a.inputDelay,        // chờ main thread rảnh
    processingDuration: a.processingDuration, // chạy handler
    presentationDelay: a.presentationDelay,   // render khung kế
    loafEntries: a.longAnimationFrameEntries, // LoAF entries để lần tới script (Part 16)
  });
});
Phân rã INP thành 3 phần → biết sửa ở đâu:
  inputDelay cao        → main thread bận lúc bấm (chia nhỏ task, Part 16)
  processingDuration cao → handler nặng (tối ưu/đẩy sang worker)
  presentationDelay cao  → render/layout nặng sau handler (Part 17)

Tương tự, LCP attribution phân rã TTFB/resource-load delay/resource-load duration/element-render delay; CLS attribution cho largestShiftTarget — phần tử đại diện cho cụm shift lớn nhất. LoAF attribution hiện không đồng đều giữa các engine, nên luôn feature-detect và giữ đường chẩn đoán bằng Performance panel.

Attribution làm payload có cardinality và rủi ro riêng tư cao hơn. Không gửi DOM text, form value hay URL/query có token; allowlist route, chuẩn hóa selector bằng tùy chọn generateTarget, cắt query khỏi sourceURL, và đặt retention/sampling riêng cho dữ liệu debug.


3. Percentile, không phải trung bình

Sai lầm phân tích kinh điển: nhìn giá trị trung bình. Trung bình bị kéo lệch và giấu đuôi xấu. Field data dùng percentile, đặc biệt p75 — Google đánh giá CWV ở phân vị 75 của các lượt xem trang đủ điều kiện.

Ví dụ minh họa INP của 5 phiên: 80, 90, 100, 120, 900 (ms)
  Trung bình = 258ms  → trông "tệ" nhưng gây hiểu lầm
  p75 = 120ms         → ít nhất 75% lượt xem ở mức này hoặc tốt hơn (mốc Google dùng)
  p99 = ~900ms        → đuôi xấu cần điều tra riêng

→ Báo cáo p75 (tuân thủ) + theo dõi p95/p99 (đuôi).

Đừng tính p75 trên trung bình client-side; gửi từng giá trị thô về backend rồi tính percentile khi tổng hợp. Trung bình của các trung bình làm mất phân phối. Năm mẫu ở ví dụ trên quá ít để ra quyết định thật — dashboard phải hiển thị cả sample count và khoảng thời gian.


4. Phân khúc (segmentation) — nơi insight thật nằm

Một con số p75 toàn cục che giấu vấn đề. Sức mạnh của RUM là chẻ theo chiều:

Phân khúc đáng theo dõi:
  - thiết bị (mobile vs desktop)   → mobile thường tệ hơn nhiều
  - loại mạng (4G vs wifi)
  - quốc gia/vùng (gần CDN edge?)
  - route/trang (trang nào tệ nhất?)
  - trình duyệt
  - new vs returning (cache nóng/lạnh)

Gắn các chiều hợp lệ vào payload RUM. “p75 INP toàn site = 180ms” có thể giấu một route mobile với p75 = 600ms — chính chỗ cần sửa. Dữ liệu mạng phía client (Network Information API) không có ở mọi browser và có thể bị giảm độ chi tiết; đừng biến giá trị thiếu thành một phân khúc giả.


5. Lấy mẫu (sampling) — đo với chi phí hợp lý

Site lớn không cần gửi 100% beacon. Lấy mẫu giảm chi phí mà vẫn đủ thống kê.

const SAMPLE_RATE = 0.1; // 10% phiên
const sampled = Math.random() < SAMPLE_RATE;
if (sampled) {
  onLCP(send); onINP(send); onCLS(send);
}

Lưu ý: lấy mẫu theo page visit/phiên theo định nghĩa analytics của bạn (quyết định một lần), không tung xúc xắc riêng cho từng metric. Tỉ lệ phù hợp phụ thuộc traffic, số route/phân khúc và độ chính xác cần có; theo dõi sample count trước khi hạ tỉ lệ, không mặc định rằng 1–10% luôn đủ.


6. CrUX — field data miễn phí của Google

Nếu chưa có RUM, Chrome UX Report (CrUX) cho field data tổng hợp từ tập người dùng Chrome đủ điều kiện và đã bật đồng bộ lịch sử/usage statistics. Đây là nguồn field mà các công cụ Google như PageSpeed Insights và báo cáo Core Web Vitals sử dụng.

Truy cập CrUX:
  - PageSpeed Insights         → field data của 1 URL (nhanh, thủ công)
  - CrUX API                   → p75 LCP/INP/CLS theo URL/origin (tự động hóa)
  - CrUX trên BigQuery         → dữ liệu lịch sử, phân tích sâu, theo quốc gia/thiết bị
  - Search Console (CWV report)→ nhóm URL pass/fail theo thời gian
# CrUX API: lấy CWV field cho một origin
curl "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$KEY" \
  -H 'Content-Type: application/json' \
  -d '{"origin":"https://example.com","metrics":["largest_contentful_paint","interaction_to_next_paint","cumulative_layout_shift"]}'

Khác biệt CrUX vs RUM riêng: CrUX API là dữ liệu tổng hợp rolling 28 ngày, từ tập Chrome đủ điều kiện, không phân khúc tùy ý; BigQuery phát hành theo tháng và có các lát cắt được công bố. RUM riêng cho dữ liệu gần thời gian thực, phân khúc & attribution tùy ý trên những browser hỗ trợ API metric tương ứng. Dùng CrUX để benchmark, RUM riêng để chẩn đoán sâu.


7. Tóm tắt

  • Đo người dùng thật bằng thư viện web-vitals; xử lý callback có thể lặp theo id/delta, và dùng sendBeacon để flush an toàn lúc visibilitychange → hidden.
  • Dùng bản Attribution để biết phần tử/script/giai đoạn nào gây LCP/INP/CLS — phân rã INP thành inputDelay/processing/presentation để biết sửa ở đâu.
  • Chuẩn hóa attribution trước khi gửi: tránh PII/token và cardinality không kiểm soát; dùng generateTarget khi selector mặc định không phù hợp.
  • Phân tích bằng percentile p75 (mốc Google dùng), không phải trung bình; theo dõi p95/p99 cho đuôi. Tính percentile ở backend từ giá trị thô.
  • Phân khúc theo thiết bị/mạng/route/quốc gia — con số toàn cục giấu vấn đề thật.
  • Lấy mẫu nhất quán và luôn theo dõi sample count để giảm chi phí mà không làm percentile vô nghĩa.
  • CrUX (PSI/API/BigQuery/Search Console) cho field data Chrome tổng hợp để benchmark; RUM riêng cho chẩn đoán sâu với coverage tùy API/browser.

Phần tiếp theo: Resource hints, priorities & Early Hints — preload/preconnect/modulepreload, fetchpriority, 103 Early Hints và Speculation Rules để rút ngắn đường tới byte quan trọng.