Debugging Web Vitals with the Attribution Build
A p75 LCP of 3.4 s or an INP of 380 ms in your dashboard tells you that a page is slow; it never tells you why. The plain web-vitals callback hands you a number and an id and nothing about the element, the request, or the main-thread phase responsible. The web-vitals/attribution build solves exactly this: every metric object gains an attribution property that names the offending element, the URL that loaded late, the interaction target, and the per-phase time breakdown. This page shows how to wire the attribution build, put the right fields on the beacon, and convert each one into a specific code change. It builds on the Web Vitals API Implementation reference and assumes you already register callbacks correctly per Using the web-vitals npm Library Correctly.
Prerequisites
web-vitalsv4 installed and pinned. The attribution build ships in the same package under theweb-vitals/attributionimport path — no separate dependency.- Callbacks registered exactly once per page load from a module-level singleton, as covered in Using the web-vitals npm Library Correctly. The attribution build does not change how you register; it only changes what the metric object carries.
- An ingestion endpoint that accepts a JSON body and can store a handful of extra string/number columns. The receiving side is the Self-Hosted Beacon Collection endpoint.
- Awareness of payload size: the attribution build pulls in more code and the attribution object is larger. Send only the named fields below — never the whole object — to stay well under the
sendBeaconceiling.
How to debug Web Vitals with the attribution build
1. Swap the import path to the attribution build
The only code change to enable attribution is the import specifier. Change from 'web-vitals' to from 'web-vitals/attribution'. The function names (onLCP, onINP, onCLS) and signatures are identical, so existing registration code keeps working.
// Before: plain build — metric has name/value/delta/id/rating only.
// import { onLCP, onINP, onCLS } from 'web-vitals';
// After: attribution build — metric also carries metric.attribution.
import { onLCP, onINP, onCLS } from 'web-vitals/attribution';
let initialized = false;
export function initVitals(report) {
if (initialized) return;
initialized = true;
onLCP(report);
onINP(report);
onCLS(report);
}
Why: the attribution build is a strict superset — every field from the plain build is still present, plus metric.attribution. Because it bundles extra diagnostic code, ship it deliberately. Many teams run the attribution build on a sampled fraction of traffic and the lighter plain build on the rest; either way the import path is the single switch.
2. Extract LCP attribution and beacon the timing sub-parts
The single most useful attribution object is for LCP. It names the element, the URL of the LCP resource, and splits the total into four additive sub-parts: timeToFirstByte, resourceLoadDelay, resourceLoadDuration, and elementRenderDelay. Those four sum to the LCP time, so the largest one is the bottleneck.
function lcpFields(metric) {
const a = metric.attribution;
return {
element: a.element, // CSS selector of the LCP element
url: a.url, // the LCP resource URL (or '')
ttfb: Math.round(a.timeToFirstByte), // server + redirect time
loadDelay: Math.round(a.resourceLoadDelay), // gap before the resource started
loadDuration: Math.round(a.resourceLoadDuration), // download time
renderDelay: Math.round(a.elementRenderDelay), // paint blocked after load
};
}
onLCP((metric) => {
beacon('LCP', metric.value, lcpFields(metric));
});
Why: these four numbers route you straight to a fix. A large timeToFirstByte is a server/CDN problem — see the FCP & TTFB analysis workflows. A large resourceLoadDelay means the browser discovered the LCP image late: add a <link rel="preload"> and fetchpriority="high". A large resourceLoadDuration means the image itself is too heavy: compress and right-size it. A large elementRenderDelay means the resource arrived but paint was blocked by render-blocking CSS or late hydration. Without the split you would guess; with it you know which one of the four to attack.
3. Extract INP attribution and split the three interaction phases
INP attribution names the interactionTarget (the element the user touched) and breaks the worst interaction into three additive phases: input delay, processing duration, and presentation delay. The phase that dominates tells you which subsystem is slow.
function inpFields(metric) {
const a = metric.attribution;
return {
target: a.interactionTarget, // selector of the slow element
type: a.interactionType, // 'pointer' or 'keyboard'
inputDelay: Math.round(a.inputDelay), // main thread busy before handler
processing: Math.round(a.processingDuration),// your event handlers + framework
presentation: Math.round(a.presentationDelay), // layout, paint, next frame
loadState: a.loadState, // doc state at interaction time
};
}
onINP((metric) => {
beacon('INP', metric.value, inpFields(metric));
});
Why: each phase has a distinct remedy. A high inputDelay means the main thread was already blocked when the user acted — break up long tasks with scheduler.yield so the thread is free to accept input. A high processingDuration is your own handler doing too much synchronously: defer non-urgent work, debounce, or move computation off the critical path. A high presentationDelay means the work finished but rendering the next frame was expensive — shrink the DOM update or avoid forced reflows. The loadState field flags interactions that happened mid-load, which are often the worst and point at hydration.
4. Extract CLS attribution and name the shifting element
CLS attribution identifies the single worst layout-shift in the largest burst: largestShiftTarget is the element that moved, largestShiftValue is its contribution, and largestShiftSource describes the node and its before/after rectangles. That selector is usually enough to fix the shift outright.
function clsFields(metric) {
const a = metric.attribution;
const src = a.largestShiftSource; // may be undefined if none captured
return {
shiftTarget: a.largestShiftTarget, // selector of the worst-shifting node
shiftValue: Number(a.largestShiftValue.toFixed(4)),
shiftTime: Math.round(a.largestShiftTime), // when in the load it happened
// before/after bounding rects pinpoint the jump direction and size
fromRect: src ? rect(src.previousRect) : null,
toRect: src ? rect(src.currentRect) : null,
};
}
function rect(r) { return { x: Math.round(r.x), y: Math.round(r.y),
w: Math.round(r.width), h: Math.round(r.height) }; }
onCLS((metric) => {
beacon('CLS', metric.value, clsFields(metric));
});
Why: CLS in aggregate is uninterpretable — you cannot reproduce a 0.18 score without knowing what moved. largestShiftTarget gives you the exact selector, and the before/after rectangles in largestShiftSource show direction and magnitude. An image that jumped because it had no width/height attributes gets an explicit aspect-ratio; an ad slot that pushed content down gets a reserved fixed-height container. The largestShiftTime tells you whether the shift came from initial render or a late injection.
5. Send one beacon with the attribution fields attached
Attach the attribution fields to the same single batched beacon you already send for the metric value. Keep the plain fields (value, delta, id, rating) and nest the diagnostic fields under an attribution key so the storage schema stays clean.
const ENDPOINT = '/rum';
const buffer = new Map();
function beacon(name, value, attribution) {
buffer.set(name, { name, value: Math.round(value), attribution });
}
function flush() {
if (buffer.size === 0) return;
const body = JSON.stringify({
url: location.pathname,
metrics: [...buffer.values()],
});
buffer.clear();
if (navigator.sendBeacon && navigator.sendBeacon(ENDPOINT, body)) return;
fetch(ENDPOINT, { method: 'POST', body, keepalive: true,
headers: { 'Content-Type': 'application/json' } });
}
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush();
});
addEventListener('pagehide', flush);
Why: navigator.sendBeacon is the only delivery the browser guarantees during unload, and one beacon carrying all metrics plus their attribution is atomic on the receiving side. Storing the attribution fields as columns lets you aggregate them: group bad LCP by attribution.element to find which hero image hurts the most pages, or pivot INP by the dominant phase to decide whether to fix handlers or yielding first.
Verifying it works
- DevTools Console: temporarily log
metric.attributioninside each callback. For LCP confirmelement,url, and the four timing sub-parts are present and that they sum to roughlymetric.value. For INP confirminteractionTargetand the three phases sum to the interaction time. - Network panel: filter for your endpoint and inspect the beacon payload. Each metric object should carry a nested
attributionblock with the named fields — not the entire raw attribution object, which would bloat the request. - Reproduce from the selector: take a beaconed
attribution.elementorlargestShiftTargetand rundocument.querySelector(selector)in the console on the live page. It should resolve to the exact node, confirming the selector is actionable. - RUM dashboard: add a panel grouping bad LCP (p75 above 2.5 s) by
attribution.element. A single selector dominating the list is your highest-leverage fix. Do the same for INP grouped by dominant phase. - Cross-check: the Web Vitals Chrome extension also surfaces attribution; its reported LCP element and INP phases should match your beaconed values for the same page view within rounding.
Edge cases & gotchas
attribution.urlis empty for text LCP. When the LCP element is text (an<h1>, not an image), there is no resource, sourlis'',resourceLoadDelayandresourceLoadDurationare 0, and the time is split betweentimeToFirstByteandelementRenderDelay. Branch on a non-emptyurlbefore blaming image loading.- Selectors can be long and brittle.
attribution.elementis a generated CSS path that can be verbose. Store it, but key your aggregations on a stable prefix or a manually taggeddata-vital-idwhere you need durable grouping across deploys. - Attribution only describes the worst case. CLS attribution reports the single largest shift, not every shift; INP reports the worst interaction, not all of them. Fixing the named one usually moves the metric, but re-measure — the next worst becomes the new bottleneck.
- Bundle weight. The attribution build is larger than the plain build. If bundle size matters, dynamically
import('web-vitals/attribution')for a sampled fraction of sessions and load the plain build for the rest. - Don’t beacon the raw object.
metric.attributioncan include DOM references and large rect data. Always project to the named primitive fields shown above; serializing the whole object risks circular structures and oversized beacons. - Safari coverage. INP and some attribution sub-parts depend on Event Timing support; on browsers without it the
attributionobject is present but phases may be partial. Treat missing phases as null rather than zero in aggregation.
FAQ
What is the difference between web-vitals and web-vitals/attribution?
They are the same package. The web-vitals/attribution import path returns metric objects with an extra attribution property naming the responsible element, URL, interaction target, and per-phase timing. The function names and signatures are identical, so only the import specifier changes.
Do the LCP attribution sub-parts add up to the LCP value?
Yes. timeToFirstByte, resourceLoadDelay, resourceLoadDuration, and elementRenderDelay are additive and sum to the LCP time. The largest sub-part is the bottleneck, which tells you whether to fix the server, resource discovery, the download, or render-blocking.
Which INP phase should I optimize first?
Whichever phase dominates the beaconed breakdown. A high input delay points at a busy main thread to be broken up with yielding; a high processing duration points at your own event handlers; a high presentation delay points at expensive rendering of the next frame.
Related
- Web Vitals API Implementation — the parent reference covering PerformanceObserver, buffered entries, and the library internals.
- Using the web-vitals npm Library Correctly — registering callbacks once and batching one beacon, the foundation this build sits on.
- Debugging INP Spikes in Production — applying INP phase attribution to chase down responsiveness regressions in the field.