Web Vitals Attribution API 2026: Debug LCP, INP, CLS từ Field Data thật

Trích xuất và phân tích attribution data cho LCP, INP, CLS bằng web-vitals v5. Subparts, LoAF, sendBeacon, và query p75/p95 theo device class.

Web Vitals Attribution API Guide 2026

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 (thư viện web-vitals v4/v5) trả về đối tượng attribution đi kèm mỗi lần đo LCP, INP, CLS trong field.
  • Với LCP, attribution phơi bày bốn subpart: timeToFirstByte, resourceLoadDelay, resourceLoadDuration, elementRenderDelay. Bạn biết chính xác chỗ nào cần fix.
  • Với INP, attribution báo interactionTarget, inputDelay, processingDuration, presentationDelaylongAnimationFrameEntries giúp gán INP về hàm JS cụ thể.
  • Với CLS, largestShiftTargetlargestShiftSources chỉ ra phần tử DOM và ảnh/font/quảng cáo gốc gây ra layout shift lớn nhất.
  • Gửi payload attribution vào BigQuery, Sentry, hoặc DataDog qua navigator.sendBeacon; phân tách theo device class (mobile/desktop) và p75/p95, không dùng average.
  • Attribution build tăng bundle khoảng 2–3 KB gzip so với web-vitals cơ bản. Với tôi, chi phí này quá xứng đáng cho khả năng chẩn đoán.

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ặc fetchpriority="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:

  1. largestShiftTarget chỉ 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.
  2. previousRect vs currentRect cho 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.
  3. loadState phân loại thời điểm shift xảy ra. Shift trong loading thường sửa bằng dimensions hoặc aspect-ratio. Shift trong complete thườ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:

  1. Lấy mẫu theo rating. Gửi 100% cho rating === 'poor', 10% cho needs-improvement, 1% cho good. Bạn giữ được insight chẩn đoán ở đuôi phân phối, chỗ thực sự cần fix.
  2. Strip fields. Loại lcpEntrylongAnimationFrameEntries ở client trước khi sendBeacon. Giữ lại URL, selector, timestamps, subpart durations.
  3. 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ùng module preload.
  • Nhầm web-vitals với web-vitals/attribution. Bản base không có trường attribution. 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ệt navigate, 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.

Nadia El-Sayed
Về Tác Giả Nadia El-Sayed

Core Web Vitals specialist focused on real-user monitoring. Believes synthetic-only perf testing is a comforting lie.