Cập nhật: 11 tháng 9, 2026
Web Vitals Attribution API là phần mở rộng của thư viện web-vitals v4+ cung cấp dữ liệu chẩn đoán chi tiết ngay bên trong RUM (Real User Monitoring): phần tử nào gây LCP chậm, tương tác nào tạo ra INP tệ, và nguồn layout shift nào đang phá vỡ CLS. Tất cả đều đến từ trình duyệt của người dùng thật, không phải phòng lab. Thành thật mà nói, nếu bạn vẫn debug Core Web Vitals bằng Lighthouse trên MacBook, bạn đang nhìn vào một phiên bản đã được lý tưởng hoá của thực tế. Bài này chỉ ra cách trích xuất, gửi, và phân tích attribution data ở p75 và p95 theo device class, có kèm ví dụ SQL và snippet client thật.
Web Vitals Attribution API là gì?
Nói ngắn gọn, Web Vitals Attribution API là tập hợp các trường chẩn đoán bổ sung được thư viện web-vitals của GoogleChrome trả về khi bạn dùng entry point web-vitals/attribution thay vì bản base. Mỗi metric callback (LCP, INP, CLS, TTFB, FCP) mang thêm một object attribution chứa selector CSS, timestamp, và các subpart giúp xác định chính xác nguyên nhân gốc. Trong kinh nghiệm của tôi, đây là bước chuyển quan trọng nhất từ "biết trang chậm" sang "biết chính xác dòng code nào cần sửa".
API ra đời chính thức trong web-vitals v3 (giữa 2023) nhưng phải đến v4 (Q1 2024) và v5 (đầu 2026) mới ổn định các trường attribution cho INP, đồng thời tích hợp Long Animation Frames API để xác định script bên thứ ba. Chrome, Edge, và các trình duyệt Chromium đều hỗ trợ đầy đủ; Safari 18+ và Firefox 128+ hỗ trợ một phần (LCP, CLS attribution nhưng chưa có INP entries).
Điểm khác biệt so với PerformanceObserver thô: attribution API đã chuẩn hoá và tổng hợp nhiều PerformanceEntry (LargestContentfulPaint, LayoutShift, EventTiming, LongAnimationFrame) thành một cấu trúc dùng được ngay trong pipeline RUM, bạn không cần tự stitch lại. Nó thay thế cho việc phải viết cỡ 200 dòng logic quan sát rải rác khắp nơi.
Cài đặt và cấu hình web-vitals v5
Cài thư viện qua npm hoặc load qua CDN. Với dự án dùng bundler:
npm install web-vitals@^5
Sau đó tạo một file rum.ts đăng ký tất cả metric với attribution build. Chú ý import từ web-vitals/attribution, không phải web-vitals. Đây là lỗi phổ biến nhất tôi thấy các bạn dev gặp phải, và hệ quả là mất luôn trường attribution:
// rum.ts, chạy sớm trong <head>
import { onLCP, onINP, onCLS, onTTFB, onFCP } from 'web-vitals/attribution';
const RUM_ENDPOINT = 'https://rum.example.com/v1/vitals';
const SESSION_ID = crypto.randomUUID();
function reportMetric(metric) {
const body = JSON.stringify({
name: metric.name, // 'LCP' | 'INP' | 'CLS' | ...
value: metric.value,
id: metric.id,
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
attribution: metric.attribution,
navigationType: metric.navigationType,
sessionId: SESSION_ID,
href: location.href,
deviceMemory: navigator.deviceMemory,
connection: navigator.connection?.effectiveType,
timestamp: Date.now(),
});
// sendBeacon không chặn unload, quan trọng để không mất INP cuối trang
navigator.sendBeacon(RUM_ENDPOINT, body);
}
onLCP(reportMetric);
onINP(reportMetric);
onCLS(reportMetric);
onTTFB(reportMetric);
onFCP(reportMetric);
Về bundle size, attribution build thêm khoảng 2.6 KB gzip (v5.0) so với 1.5 KB của base build. Với dự án chạy quảng cáo hoặc thương mại điện tử, đây là mức phí chấp nhận được để có diagnostic từ 100% traffic thay vì lấy mẫu 1% qua Lighthouse.
Debug LCP với 4 subpart attribution
Attribution của LCP là công cụ mạnh nhất trong bộ ba, vì nó chia thời gian LCP thành bốn khoảng có ý nghĩa nghiệp vụ:
metric.attribution = {
element: 'main > article > img.hero', // CSS selector của phần tử LCP
url: 'https://cdn.example.com/hero.avif', // URL nếu là ảnh
timeToFirstByte: 240, // TTFB (ms)
resourceLoadDelay: 320, // TTFB tới resource fetch start
resourceLoadDuration: 610, // fetch start tới fetch end
elementRenderDelay: 180, // resource ready tới paint
lcpEntry: { ... }, // PerformanceEntry gốc
}
Bốn số này ánh xạ trực tiếp vào bốn "chiến thuật fix" khác nhau:
- TTFB cao (> 400 ms tại p75): vấn đề ở backend hoặc CDN edge. Xem chi tiết trong bài giảm TTFB với Edge Rendering, ISR, Streaming SSR.
- resourceLoadDelay cao: resource được discover muộn. Fix bằng
<link rel="preload">hoặcfetchpriority="high". Xem bài tối ưu LCP với fetchpriority, Preload, Early Hints. - resourceLoadDuration cao: ảnh quá lớn hoặc CDN chậm. Chuyển sang AVIF, dùng responsive srcset.
- elementRenderDelay cao (> 100 ms): main thread bị chặn bởi JS render-blocking. Cân nhắc streaming SSR hoặc code splitting.
Khi phân tích trong BigQuery, tôi thường tạo query gom các phiên p75 và tính median của từng subpart. Nếu resourceLoadDelay chiếm 45% của LCP tổng, ưu tiên số 1 là preload; nếu elementRenderDelay chiếm 30%, main-thread mới là kẻ tình nghi.
INP attribution: gán chậm về đúng handler
INP (Interaction to Next Paint) là metric khó debug nhất, vì nó chỉ xảy ra khi người dùng thao tác. Attribution API biến field debugging thành khả thi:
metric.attribution = {
interactionTarget: 'button#add-to-cart', // selector phần tử nhận click
interactionType: 'pointer', // 'pointer' | 'keyboard'
interactionTime: 12480, // timestamp khi user tương tác
inputDelay: 32, // main thread tới handler bắt đầu
processingDuration: 220, // handler chạy
presentationDelay: 96, // handler xong tới paint
loafScripts: [ // Long Animation Frame scripts
{ sourceLocation: 'analytics.js:142', duration: 78, invoker: 'setTimeout' },
{ sourceLocation: 'https://cdn.thirdparty.com/tag.js:14', duration: 65 }
],
longAnimationFrameEntries: [ ... ],
}
Cách đọc: tổng ba subpart (inputDelay + processingDuration + presentationDelay) xấp xỉ giá trị INP. Nếu inputDelay lớn, main thread đã bận trước khi user click, thường do hydration hoặc script bên thứ ba đang khởi chạy. Nếu processingDuration lớn, handler của bạn chậm, và bạn có thể chia nhỏ bằng scheduler.yield(). Nếu presentationDelay lớn, DOM update quá tốn kém; hãy thử CSS content-visibility hoặc virtualization.
Trường loafScripts là điểm ngoặt thật sự. Nó chỉ đích danh file và dòng của long task cản trở, bao gồm cả script bên thứ ba (chỉ hiện URL, không hiện dòng vì CORS). Chi tiết cách phân tích LoAF entries có trong bài Long Animation Frames API cho INP.
Kể một ví dụ. Trong một dự án e-commerce đầu 2026, chúng tôi phát hiện p75 INP mobile là 340 ms. Attribution báo 68% thời gian đến từ inputDelay, và loafScripts trỏ về script chat widget của bên thứ ba đang chạy heartbeat mỗi 2 giây. Chuyển widget đó sang Partytown chạy trên Web Worker giúp p75 INP xuống 180 ms, mà không cần đổi một dòng code handler nào của chúng tôi. Đó là loại insight mà lab data không bao giờ chỉ ra được.
CLS attribution: tìm phần tử gây shift
CLS là tổng của các layoutShiftEntries có score cao nhất trong session window 5 giây. Attribution phơi bày cụ thể shift lớn nhất:
metric.attribution = {
largestShiftTarget: 'main > div.article-body > iframe',
largestShiftTime: 1830,
largestShiftValue: 0.087,
largestShiftSource: { // phần tử di chuyển trong shift
node: '<iframe id="ad-slot-1">',
previousRect: { x: 0, y: 720, width: 320, height: 0 },
currentRect: { x: 0, y: 720, width: 320, height: 250 }
},
loadState: 'loading', // 'loading' | 'dom-interactive' | 'complete'
}
Ba insight quan trọng:
largestShiftTargetchỉ ra selector CSS của element chứa shift. Trong ví dụ trên, iframe quảng cáo mở rộng chiều cao từ 0 lên 250 px sau khi ad load. Kịch bản kinh điển.previousRectvscurrentRectcho phép tính chính xác khoảng cách bị dịch. Nếu delta lớn hơn 100 px, gần như chắc chắn là ảnh/iframe không cówidth/height.loadStatephân loại thời điểm shift xảy ra. Shift trongloadingthường sửa bằng dimensions hoặcaspect-ratio. Shift trongcompletethường do JS chèn banner cookie hoặc lazy content, và bạn cần đặt trước placeholder.
Phần lớn CLS field data đến từ ads, embed video, và font swap. Với font swap, xem bài sửa CLS với size-adjust và RUM để hiểu cách dùng size-adjust và fallback metrics cho các font cụ thể.
Gửi attribution data lên RUM backend
Đối tượng attribution có thể lên đến 3–4 KB JSON cho một trang phức tạp (chủ yếu vì lcpEntry, loafScripts, và largestShiftSources). Với p75 traffic hàng triệu pageview mỗi ngày, chi phí ingestion cần được kiểm soát chặt. Ba chiến lược tôi hay dùng:
- Lấy mẫu theo rating. Gửi 100% cho
rating === 'poor', 10% choneeds-improvement, 1% chogood. Bạn giữ được insight chẩn đoán ở đuôi phân phối, chỗ thực sự cần fix. - Strip fields. Loại
lcpEntryvàlongAnimationFrameEntriesở client trước khisendBeacon. Giữ lại URL, selector, timestamps, subpart durations. - Batch qua single beacon. Đẩy tất cả metric vào một mảng và gửi một lần trong
visibilitychange === 'hidden'. Cách này giảm số request từ 5+ xuống 1 mỗi session.
const queue = [];
function enqueue(metric) {
// Loại field nặng, giữ diagnostic quan trọng
const light = {
name: metric.name,
value: metric.value,
rating: metric.rating,
element: metric.attribution?.element ?? metric.attribution?.interactionTarget ?? metric.attribution?.largestShiftTarget,
url: metric.attribution?.url,
subparts: {
ttfb: metric.attribution?.timeToFirstByte,
loadDelay: metric.attribution?.resourceLoadDelay,
loadDuration: metric.attribution?.resourceLoadDuration,
renderDelay: metric.attribution?.elementRenderDelay,
inputDelay: metric.attribution?.inputDelay,
processing: metric.attribution?.processingDuration,
presentation: metric.attribution?.presentationDelay,
},
};
queue.push(light);
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && queue.length) {
navigator.sendBeacon(RUM_ENDPOINT, JSON.stringify({ session: SESSION_ID, metrics: queue }));
queue.length = 0;
}
});
Phân tích p75, p95 theo device class
Đây là điểm mù lớn nhất mà tôi thấy các team hay mắc: nhìn average của một metric. Trung bình che giấu đuôi phân phối. Google xếp hạng Core Web Vitals theo p75 CrUX (Chrome User Experience Report) theo device type, nên nếu team bạn nhìn average, bạn có thể vượt qua CI nội bộ nhưng vẫn trượt trong CrUX. Luôn tính percentile, và luôn tách theo device.
SQL mẫu trong BigQuery (giả sử bảng rum_vitals có cột name, value, rating, device_class, connection, attribution):
SELECT
device_class,
APPROX_QUANTILES(value, 100)[OFFSET(75)] AS p75,
APPROX_QUANTILES(value, 100)[OFFSET(95)] AS p95,
COUNTIF(rating = 'poor') / COUNT(*) AS poor_share,
APPROX_TOP_COUNT(attribution.element, 10) AS worst_offenders
FROM rum_vitals
WHERE name = 'LCP'
AND event_date BETWEEN '2026-09-01' AND '2026-09-08'
GROUP BY device_class;
Tách device_class thành ít nhất ba nhóm: mobile-low (deviceMemory ≤ 2, connection 3g/4g slow), mobile-mid, và desktop. Trong dự án gần đây của tôi, LCP p75 desktop là 1.8 s (green) nhưng p75 mobile-low lại là 4.2 s (poor). Số tổng gộp là 2.4 s trông đẹp, nhưng nó che giấu vấn đề nghiêm trọng của 35% người dùng.
Dashboard Grafana / Looker tối thiểu
Ba view tôi khuyên xây trước tiên: (1) đường percentile 7 ngày cho từng metric, tách theo device_class; (2) bảng "top 20 element causing poor LCP" join theo attribution.element; (3) heatmap INP theo interactionTarget. Với ba view này, mọi cuộc họp perf review đều xoay quanh nguyên nhân cụ thể, và không còn tranh cãi kiểu "trang có chậm không".
Lỗi thường gặp khi triển khai attribution
- Load web-vitals sau khi trang đã tương tác. Bạn mất LCP entry và có INP không chính xác. Luôn đặt trong
<head>, hoặc dùngmodule preload. - Nhầm
web-vitalsvớiweb-vitals/attribution. Bản base không có trườngattribution. Kiểm tra import path đầu tiên. - Gửi 100% traffic với payload đầy đủ. Chi phí BigQuery ingest sẽ nhân đôi so với web-vitals base. Sampling và field stripping là bắt buộc ở quy mô lớn.
- Không track
navigationType. Field data trộn prerender/bfcache/reload có phân phối rất khác. Luôn phân biệtnavigate,reload,back-forward,prerender. - Cross-origin script không lộ file/dòng. LoAF entries của script bên thứ ba chỉ có URL, không có source location. Điều này đúng theo spec để bảo vệ privacy, không phải bug.
- Chỉ đo trên desktop. Emulate mobile trong Lighthouse không thay thế RUM. CPU throttle của Lighthouse không match với thiết bị Android thực; nhiệt độ, background app, và version Chrome đều ảnh hưởng.
Câu hỏi thường gặp
Web Vitals Attribution API khác gì với Web Vitals thường?
Bản attribution mở rộng mỗi metric bằng object attribution chứa selector CSS, subpart durations, và các PerformanceEntry gốc. Bản base chỉ trả về giá trị số. Attribution phù hợp cho RUM diagnostic; base phù hợp cho tracking đơn giản.
Attribution API hỗ trợ Safari và Firefox không?
Có một phần. Safari 18+ và Firefox 128+ hỗ trợ LCP và CLS attribution. INP attribution với longAnimationFrameEntries hiện chỉ có trên Chromium (Chrome, Edge, Opera). Với các trình duyệt khác, callback vẫn chạy nhưng trường loafScripts sẽ rỗng.
Làm sao debug INP mà không có Long Animation Frames?
Bạn vẫn có interactionTarget, inputDelay, processingDuration, và presentationDelay. Đủ để xác định handler chậm; bạn chỉ mất khả năng chỉ đích danh script bên thứ ba. Trên Safari/Firefox, kết hợp với PerformanceObserver longtask để có xấp xỉ.
Bao nhiêu bundle size để dùng attribution build?
Trong web-vitals v5.0, attribution build là khoảng 2.6 KB gzip, so với 1.5 KB của base build. Đây là chi phí một lần và không chặn render nếu bạn đặt defer. So với lợi ích chẩn đoán 100% traffic thay vì lấy mẫu Lighthouse, chi phí này rất nhỏ.
Nên dùng CrUX hay RUM tự dựng với attribution?
Dùng cả hai. CrUX cho bạn baseline mà Google dùng để xếp hạng, không thể thay thế. RUM với attribution cho bạn diagnostic granular ở real time, phân đoạn theo route, feature flag, và bản build. CrUX là "điểm cuối tháng"; RUM là "camera an ninh 24/7".
attribution.element có ổn định qua các bản deploy không?
CSS selector do web-vitals sinh ra dựa trên tag, class, và id, nên nó thay đổi khi bạn đổi markup. Để có ID ổn định cho analytics, thêm data-vital-id="hero-image" vào phần tử quan trọng và tự extract từ attribution.lcpEntry.element trong callback. Cách này giữ analytics query nhất quán qua nhiều bản.




