How to Attribute a Failing INP to the Component That Caused It
This page extends RUM Beacons & Field Data Collection inside Core Web Vitals & Measurement. You have field data, your p75 INP is 340ms, and the ticket says "improve INP". Nobody knows which interaction is slow, on which route, in which component — so the work stalls or, worse, someone optimises a handler that was never in the sample.
The attribution build of web-vitals closes that gap: it reports the element that was interacted with, the event type, and the phase breakdown, all in the same callback that reports the value.
Rapid Diagnosis: What You Need in the Payload
- The interaction target selector. A CSS selector for the element that received the event. This is the single field that makes the metric actionable.
- The event type.
pointerdown,keydownandclickfail for different reasons; a slowkeydownusually means synchronous work per keystroke, a slowclickusually means a heavy state update. - The three phases. Input delay, processing duration and presentation delay. Which one dominates decides the entire fix strategy.
- The route pattern and device class. The same component can pass on desktop and fail on a mid-tier Android, and you need to know which population you are fixing for.
Root Cause Analysis: Why "Improve INP" Stalls
1. The value arrives without a subject. The standard web-vitals build reports a number. Without the target element, the team guesses — and guesses are biased toward the interactions engineers use, not the ones users struggle with.
2. Selectors are unstable. Hashed CSS-module class names change every build, so a selector captured last week matches nothing today. Aggregation by raw selector then produces thousands of one-off groups instead of a ranked list.
3. The phase split is ignored. Teams optimise handlers because that is the familiar work, even when the field data says the handler ran for 20ms and the thread was blocked for 200ms before it started.
4. One rogue interaction hides behind the p75. INP reports a near-worst interaction per visit. A rarely used control that takes 900ms can fail the metric for the sessions that touch it while every common interaction is fast — visible only when you group by target.
Step-by-Step Resolution
1. Capture the target and the phases.
import { onINP } from 'web-vitals/attribution';
onINP((metric) => {
const a = metric.attribution;
send({
name: 'INP', value: Math.round(metric.value), rating: metric.rating,
target: a.interactionTarget, // CSS selector for the element
eventType: a.interactionType, // 'pointer' | 'keyboard'
inputDelay: Math.round(a.inputDelay),
processing: Math.round(a.processingDuration),
presentation: Math.round(a.presentationDelay),
longestScript: a.longAnimationFrameEntries?.[0]?.scripts?.[0]?.sourceURL
});
});
// trade-off: the attribution build is roughly 2KB larger than the standard one
// and does slightly more work per interaction. That is a good trade for a metric
// you intend to act on — but keep it out of pages that never ship fixes.
Expected outcome: every INP row now names an element and a dominant phase.
2. Make selectors stable enough to group. Add an explicit attribute to interactive components and prefer it over the generated selector.
// <button data-perf-id="filter-panel/apply">Apply</button>
function stableId(selector) {
if (!selector) return null;
const el = document.querySelector(selector);
const owned = el?.closest('[data-perf-id]');
return owned ? owned.dataset.perfId : selector.replace(/\.[a-z]+_[a-z0-9]{5,}/gi, '');
}
// trade-off: data attributes are one more thing to maintain and they can drift
// from the component that owns them. Apply them at the boundary components
// (panels, forms, toolbars) rather than to every button.
Expected outcome: the dashboard groups by roughly a hundred meaningful component IDs rather than thousands of hashed selectors.
3. Rank by field impact, not by worst case. The interaction to fix first is the one whose total contribution — frequency multiplied by the amount it exceeds 200ms — is largest.
-- Ranked list of what to fix, over the last 7 days.
SELECT target,
count(*) AS interactions,
approx_quantile(value, 0.75) AS p75,
sum(greatest(value - 200, 0)) / 1000.0 AS excess_seconds
FROM inp_events
WHERE ts > now() - interval '7 days'
GROUP BY target
HAVING count(*) > 100
ORDER BY excess_seconds DESC
LIMIT 20;
-- trade-off: the HAVING clause hides genuinely broken but rarely used controls.
-- Run a second query without it, sorted by p75, to catch those separately.
Expected outcome: a prioritised list where the top entry is usually responsible for a third or more of the total excess.
4. Route each row to the right fix by phase. Where input delay dominates, the culprit is other work on the thread — hydration, a third-party tag, a timer — and the remedies are in Hydration & Partial Rehydration Strategies and Third-Party Script Performance. Where processing dominates, the handler itself needs splitting — see optimizing INP with scheduler.yield(). Where presentation delay dominates, the render after the handler is expensive: shrink the DOM affected, use containment, or avoid layout-triggering reads.
Verification
Reproduce the top-ranked interaction in the lab before changing anything. Take the target and the device class from the field row, set the Performance panel to the matching CPU throttle, and record just that interaction. The panel's interaction track should show a total in the same range as the field p75 — if it does not, your throttle profile is wrong and any improvement you measure will not transfer.
After the fix, confirm in both places. In the lab, the same recording should show the dominant phase reduced. In the field, watch that target's p75 specifically rather than the site aggregate:
SELECT date_trunc('day', ts) AS d,
approx_quantile(value, 0.75) AS p75, count(*) AS n
FROM inp_events
WHERE target = 'filter-panel/apply' AND ts > now() - interval '14 days'
GROUP BY 1 ORDER BY 1;
A successful fix shows the target's own p75 dropping within a day of the deploy, followed by the site-wide p75 moving by a smaller amount — the difference being how much of your traffic touches that control.
FAQ
Why is interactionTarget sometimes null?
Because the element was removed from the DOM before the metric finalised, which is common for controls inside a menu or modal that closes as part of the interaction. Capture a stable identifier at event time — for example, reading the nearest data-perf-id in a capture-phase listener — and attach it to the report, so a disappearing element still yields a usable group.
Does the attribution build slow the page down?
Marginally, and far less than the problem it diagnoses. It adds around 2KB and a small amount of bookkeeping per interaction, using data the browser already produces. The practical risk is not the library but what you do with it: serialising a large attribution payload on every interaction, rather than batching one report at page hide, is what would actually cost you.
How do I attribute an interaction inside a third-party iframe?
You cannot, and that is intentional — INP is measured per document, so an interaction inside a cross-origin iframe is attributed to that frame, not to your page. What you can measure is the effect the iframe has on your main thread. If your input delay is high and your own scripts are idle, profile with the frame removed to quantify its contribution.
Should I report every interaction or only the final INP?
Report the final value per page view for the dashboard, and sample a small fraction of individual interactions separately for debugging. The final value is what the metric assesses, so percentile charts must be built from it. Interim entries are useful for understanding the shape of a session — whether one control is consistently slow or one unlucky interaction spiked — but mixing them into the same table makes every percentile ambiguous.
What if the same component is slow on only one route?
That is a useful finding, not a complication: include the route pattern in the grouping key so a component's cost can differ per context. The usual explanation is that the route renders far more instances of it, or that a heavier sibling on that page is competing for the thread. Grouping by target alone would average the two and hide it, so keep target and route as separate dimensions and look at their combination.
Does attribution work for interactions during page load?
Yes, and those are often the worst ones. An interaction that arrives while hydration is still running shows a large input delay and a small processing duration, which the phase split identifies immediately. Segment by time since navigation to separate them: the early population is fixed by reducing startup work, while the steady-state population is fixed inside the handler. Treating them as one group produces a fix that helps neither.
Related
- RUM Beacons & Field Data Collection — the pipeline that carries this attribution to your dashboard.
- Profiling Event Handlers for INP — reproduce the attributed interaction in the Performance panel.
- Optimizing INP with scheduler.yield() — the fix when processing duration is the dominant phase.