How to Attribute INP to the Route That Actually Caused It
This guide belongs to Soft Navigations & SPA Metrics within Core Web Vitals & Measurement. The scenario is common and expensive: Search Console flags your category pages for INP above 200ms, a sprint goes into optimising them, and the field number does not move — because the slow interaction was on the checkout view that users reach by in-app navigation, and every metric from that session was filed under the landing URL.
INP is the worst interaction (ignoring one outlier per 50) over the whole document lifetime. In a single-page app the document lifetime spans every route the user visits, so the INP value is effectively "the slowest interaction anywhere in the session", attributed to wherever the session began. To fix the right code you need to know where the interaction happened.
Rapid Diagnosis
- Compare INP by landing page with INP by session length. If INP worsens steadily with pages-per-session, interactions on deeper routes are driving it.
- Reproduce the landing route alone. Load
/shoes, interact only there, and measure. If it is comfortably under200msin a throttled lab run, its field INP is inherited. - Look at the interaction target. The
web-vitalsattribution build reportsinteractionTargetas a CSS selector. A selector like#place-orderon a category page's INP is a giveaway. - Check route-level traffic. Routes reached only by soft navigation will have no rows in any document-URL report — absence of data is itself a clue.
Root Cause Analysis
1. The metric is document-scoped by definition. Event Timing entries carry no route; the browser does not know your router exists. Any attribution to a route must come from your code.
2. The "worst interaction" is a maximum across routes. One slow route contaminates the value of every session that passes through it, regardless of where those sessions began.
3. The interaction target is ambiguous across routes. Shared components — a header search box, a cart drawer — appear on every route. The selector alone does not tell you which view's state made the interaction slow.
4. Late interactions are reported late. The final INP is only known when the page is hidden. If you capture the route at report time rather than at interaction time, you record the last route visited, not the one where the slow interaction occurred.
Step-by-Step Resolution
1. Keep a timestamped route history
// route-history.js
export const history = [{ route: location.pathname, start: 0 }];
export function pushRoute(route) {
history.push({ route, start: performance.now() });
if (history.length > 50) history.splice(1, 1); // keep the landing entry
}
export function routeAt(time) {
for (let i = history.length - 1; i >= 0; i--) if (history[i].start <= time) return history[i].route;
return history[0].route;
}
// trade-off: storing route templates ("/product/:id") rather than concrete paths
// keeps cardinality low but loses which product was slow. Store the template in
// RUM and the concrete path only in sampled debug sessions.
Call pushRoute(to.matched.at(-1).path) (Vue Router) or the equivalent template in your router's after-navigation hook.
Expected outcome: any timestamp on the page can be mapped back to the route that was on screen.
2. Tag INP with the route at the interaction's start time
import { onINP } from 'web-vitals/attribution';
import { routeAt, history } from './route-history.js';
onINP(({ value, attribution }) => {
report({
metric: 'INP',
value: Math.round(value),
route: routeAt(attribution.interactionTime), // where it happened
landing: history[0].route, // where the session began
target: attribution.interactionTarget,
phase: { input: attribution.inputDelay, processing: attribution.processingDuration,
presentation: attribution.presentationDelay },
});
});
// trade-off: this still reports ONE INP per session — the worst. A route that
// is consistently 180ms will never surface if another route is worse. Step 3
// adds per-route maxima to fix that.
Expected outcome: the session's INP is filed under the route where the worst interaction occurred.
3. Report the worst interaction per route, not just per session
const worstByRoute = new Map();
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (!e.interactionId) continue;
const route = routeAt(e.startTime);
const prev = worstByRoute.get(route);
if (!prev || e.duration > prev.duration) {
worstByRoute.set(route, { duration: e.duration, name: e.name,
target: e.target?.closest('[data-component]')?.dataset.component || e.target?.tagName });
}
}
}).observe({ type: 'event', durationThreshold: 40, buffered: true });
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && worstByRoute.size) {
navigator.sendBeacon('/rum/inp-routes', JSON.stringify([...worstByRoute]));
}
});
// trade-off: durationThreshold 40 drops the fastest interactions, so a route
// with only fast ones reports nothing. That is fine for finding problems but
// means "no row" ≠ "no traffic" — count interactions separately if you need it.
Expected outcome: every route visited in a session gets its own worst-interaction record, so p75 per route can be computed directly.
4. Attribute shared components to the route's state
For components present on every route, add the route to their component tag so a slow cart drawer on /checkout is distinguishable from the same drawer on /shoes. A data-component attribute on the component's root, combined with the route from step 1, is usually enough.
Expected outcome: the "header search is slow everywhere" story splits into "header search is slow on /search where it filters 5,000 results", which is actionable.
Verification
Introduce a deliberate regression on one route in staging (a 150ms synchronous loop in a button handler) and confirm that only that route's p75 moves in your per-route report while the landing route stays flat. Then compare a week of production data: the sum of interactions across route rows should roughly equal the total interaction count, and the landing route's INP should drop to its own true value. That drop is not an improvement in user experience — it is the end of a misattribution — so annotate it on dashboards to avoid celebrating it.
Using the Data to Prioritise
Route-level INP lets you weight fixes by business impact. Multiply each route's share of failing interactions (above 200ms) by its traffic and its conversion importance. A checkout route with 5% of traffic and 60% of failing interactions is almost always the first fix, even though it never appears in Search Console. Communicate this clearly to stakeholders who track CrUX: fixing checkout will improve the landing pages' field INP, because those sessions will stop carrying checkout's slow interactions home.
Common Mistakes When Adding Route Attribution
Capturing the route when the beacon is sent. The beacon goes out on visibilitychange, by which time the user may be three routes further on. Always resolve the route from the interaction's own timestamp, never from location at send time.
Using concrete URLs as the route key. /product/12345 and /product/12346 share code and should share a row. Use the router's matched pattern; fall back to a regex that collapses numeric and UUID segments if your router does not expose patterns.
Forgetting the landing route's hydration. Interactions in the first seconds of a hard load overlap hydration and are slower for reasons unrelated to that view's code. Tag interactions that occurred before hydration completed (performance.getEntriesByName('hydrated') or a framework hook) so they can be analysed separately.
Splitting the sample too thin. Routes with a few dozen interactions a day produce a p75 that swings wildly. Report a minimum sample size alongside each route's value and roll rare routes into an "other" bucket for alerting purposes.
FAQ
Does fixing a deep route really improve the landing page's CrUX INP?
Yes, for sessions that pass through it. CrUX records the session's worst interaction against the landing URL, so removing the slowest interaction from the deep route lowers the INP of every session that visited it. The effect is proportional to how many sessions reach that route.
What about interactions during the navigation itself?
The click that triggers a navigation happens on the old route; its processing may include starting the new one. Attribute it to the old route by interactionTime, but record the destination too. Slow navigation clicks usually mean the router handler does synchronous work — rendering the next route before yielding — which is fixed in the router, not the destination view.
Related
- Attributing INP to a component with the attribution build — the component-level half of the same attribution problem.
- Why SPA CLS accumulates across routes — the equivalent fix for layout shift.
- Improving INP for complex single page applications — what to do once you know which route is slow.