Your field Interaction to Next Paint p75 crossed 200 ms after last week’s release, the “needs improvement” threshold went red in Search Console, and nothing in your first-party diff explains it — so you need to prove which vendor script is blocking the main thread during interactions before you can escalate.

Triage: Confirm the Regression Is Real and Field-Only

Before touching code, establish that INP actually regressed in the field and that it is not a synthetic-lab artifact or a reporting blip. Lab tools do not simulate real interactions, so an INP regression is almost always a field-first signal.

  1. Open the Chrome UX Report (CrUX) for the affected origin — via the CrUX API, the Search Console Core Web Vitals report, or the PageSpeed Insights field section. Confirm the INP p75 is above 200 ms and note the exact date the trend broke.
  2. Cross-check your own RUM. If you already attribute RUM data to third-party vendors, pull the per-vendor event_duration p75 for the window before and after the release. A single vendor’s row jumping is your first suspect.
  3. Correlate the break date against your deploy log and — critically — your tag manager change history. INP regressions frequently arrive through a tag manager or a vendor’s silent SDK update, with no commit in your own repository.
  4. Reproduce locally: open DevTools Performance, enable 4x CPU throttling, record a trace, and interact with the element users actually tap (the primary CTA, a filter, a menu). Look for a long task straddling the interaction’s input-to-paint window.

Root Cause: A Vendor Event Handler Blocks the Interaction

INP measures the full latency from a user input to the next paint, and its largest component is almost always processing time — the synchronous work that runs in event handlers before the browser can render. When a third party binds a listener to click, pointerdown, or input (analytics autocapture, session recorders, and heatmap tools all do this globally on document), that handler runs on the main thread during every matching interaction. A vendor SDK update that adds a heavier autocapture routine, or a new tag that installs a synchronous handler, lengthens processing time on interactions your first-party code never touched.

INP is reported as the p75 of a page’s interactions (roughly the worst interaction a user had, discounting extreme outliers on very interactive pages), and each interaction’s latency decomposes into three phases: input delay (time before the handler starts, usually the main thread being busy with something else), processing time (the handlers themselves running synchronously), and presentation delay (time to render the next frame). A vendor that installs a global listener inflates the processing phase directly, and if its script is also busy doing unrelated work when the tap lands, it inflates input delay too. Either way the cost is main-thread JavaScript you did not write, running on an interaction you did not instrument.

The raw event timing entry tells you an interaction was slow and gives you its processingStart/processingEnd window, but it does not tell you whose code ran inside that window — this is the same attribution gap that makes a bare longtask entry’s "self" name useless, described in the parent guide on attributing RUM data to third-party vendors. Closing the gap requires a second data source that does name the script: the long-animation-frame (LoAF) API, whose scripts[] array attributes each chunk of main-thread work to a sourceURL, along with invoker information describing what triggered it (an event listener, a timer, a promise resolution). The fix is to join the slow event entry against the LoAF that overlaps it and read off the vendor. Once you have the name, the durable remedy is to stop that script from executing synchronously on the critical path — the domain of using priority hints to control script execution and, for the worst offenders, moving the work off the main thread entirely.

An interaction’s latency decomposes into three phases, and knowing which one grew tells you what kind of fix will work. A vendor can inflate any of the three, by different mechanisms.

Three phases, three different vendor causes An interaction split into three phases along a timeline. Input delay is the time before any handler runs, inflated when a vendor is doing unrelated work as the tap lands. Processing time is the handlers running synchronously, inflated when a vendor has bound a global listener that runs on every interaction. Presentation delay is the time to render the next frame, inflated when a vendor mutates the DOM inside the handler and forces extra layout. Each phase carries the fix that applies to it. input delay processing time — handlers run presentation delay vendor busy when the tap arrives fix: defer or chunk its work vendor bound a global listener that runs on every interaction fix: remove the tag, or scope the listener vendor mutated the DOM inside the handler fix: move the write out of the handler Read the phase breakdown before the attribution: it tells you which of the three fixes could even help. A processing-phase problem is not solved by loading the vendor later — the listener is installed either way.

Resolution: Join the Slow event to the Overlapping LoAF scripts[]

Observe both entry types, and when a slow interaction fires, find the long-animation-frame whose window overlaps the event’s processing window. Within that frame, pick the scripts[] entry with the greatest totalDuration and resolve its sourceURL to a vendor. That vendor is your culprit.

// inp-attribution.js — pinpoint the vendor script behind slow interactions.
import { resolveVendor } from './vendor-registry.js'; // the canonical registry from the parent guide

// Keep a short ring buffer of recent long-animation-frames to join against.
const recentLoAFs = [];
const LOAF_BUFFER_MS = 5000;

new PerformanceObserver((list) => {
  const now = performance.now();
  for (const loaf of list.getEntries()) recentLoAFs.push(loaf);
  // Drop frames older than the buffer window so memory stays bounded.
  while (recentLoAFs.length && recentLoAFs[0].startTime < now - LOAF_BUFFER_MS) {
    recentLoAFs.shift();
  }
}).observe({ type: 'long-animation-frame', buffered: true });

/**
 * Given a slow event entry, return the worst overlapping vendor script,
 * or null if the blocking work was first-party / unattributed.
 */
function attributeInteraction(eventEntry) {
  // The event's processing window: where handler work actually runs.
  const winStart = eventEntry.processingStart;
  const winEnd = eventEntry.processingEnd;

  // Collect scripts from every LoAF that overlaps the processing window.
  const candidates = [];
  for (const loaf of recentLoAFs) {
    const loafEnd = loaf.startTime + loaf.duration;
    const overlaps = loaf.startTime <= winEnd && loafEnd >= winStart;
    if (!overlaps) continue;

    for (const script of loaf.scripts) {
      let hostname = null;
      try {
        // sourceURL is the URL of the script that ran; guard against non-URLs.
        if (script.sourceURL && script.sourceURL.startsWith('http')) {
          hostname = new URL(script.sourceURL).hostname;
        }
      } catch { /* malformed URL — skip this script, never throw in the callback */ }

      const vendor = hostname ? resolveVendor(hostname) : null;
      if (!vendor) continue; // first-party or unrecognised — not our suspect

      candidates.push({
        vendor,
        sourceURL: script.sourceURL,
        // totalDuration = execution + forced layout/style ("total" main-thread cost).
        totalDuration: script.totalDuration,
      });
    }
  }

  if (candidates.length === 0) return null;

  // The single script that blocked the main thread the most during this interaction.
  return candidates.reduce((worst, s) =>
    s.totalDuration > worst.totalDuration ? s : worst
  );
}

// Wire it to slow interactions only — durationThreshold keeps fast taps out.
new PerformanceObserver((list) => {
  for (const eventEntry of list.getEntries()) {
    // interactionId > 0 marks a real user interaction (vs. a raw untrusted event).
    if (!eventEntry.interactionId) continue;

    const culprit = attributeInteraction(eventEntry);
    if (!culprit) continue;

    navigator.sendBeacon('/rum/inp', JSON.stringify({
      inp: Math.round(eventEntry.duration),
      vendor: culprit.vendor,
      script: culprit.sourceURL,
      blockedMs: Math.round(culprit.totalDuration),
      url: location.pathname,
    }));
  }
}).observe({ type: 'event', durationThreshold: 200, buffered: true });

Three details make the join correct. Overlap is tested as loaf.startTime <= winEnd && loafEnd >= winStart — the standard interval-intersection predicate, which catches a frame that starts before the event’s processing window but extends into it. totalDuration (not duration) is the field to rank on, because it includes forced style and layout the script triggered, not just its raw execution time. And durationThreshold: 200 restricts the beacon to interactions at or above the INP “needs improvement” boundary, so you only pay to attribute the slow ones. Once beacons arrive, aggregate blockedMs at p75 grouped by vendor (using the server-side percentile from the parent guide) and the offending vendor rises to the top of the list with a name and a sourceURL.

The ring buffer matters because the two observers fire independently: LoAF entries are delivered per animation frame, while event entries are delivered after the interaction resolves, so the frame that blocked an interaction is almost always already observed by the time the slow event arrives. Buffering the last five seconds of frames guarantees the overlapping frame is still in memory when you join, while the while loop that shifts out stale frames keeps the buffer from growing without bound on a long-lived single-page-app session. If you support browsers without the LoAF API, feature-detect with PerformanceObserver.supportedEntryTypes.includes('long-animation-frame') and skip the join rather than throwing.

With the culprit named, the remediation is specific rather than a shotgun “reduce third-party code.” If the vendor’s handler does real work you need, debounce or defer it out of the interaction path — wrap the expensive body in a requestIdleCallback or a scheduler.postTask at background priority so it no longer runs synchronously before paint. If the tag is optional or consent-gated, stop injecting it on interaction-heavy routes. If it is a session recorder or heatmap tool sampling every input, reduce its sampling rate. Each of these directly shortens the processing phase the beacon just measured.

Before verifying, be clear about which evidence closes the case. Field attribution and a lab reproduction answer different objections, and a vendor conversation usually needs both.

Two pieces of evidence, two objections Field attribution from long animation frames answers the objection that the problem is not real or not widespread, because it is drawn from real users at the seventy-fifth percentile. A throttled lab reproduction answers the objection that the vendor cannot reproduce it, because it provides an exact set of steps and a device profile. Together they answer both, which is what a vendor escalation requires. field attribution per-vendor p75 from real sessions answers: "is this real, and how widespread?" cannot be dismissed as one slow laptop lab reproduction exact steps, device profile, throttling answers: "we cannot reproduce it" turns a report into a bug they can fix Either alone stalls. The field data is dismissed as unreproducible; the lab trace as unrepresentative. Send both, with the vendor's own script URL and duration quoted from the frame entry.

Verification

Deploy the attribution beacon, wait for a statistically meaningful window (at least ~1000 interactions on the affected route), and confirm a single vendor dominates the blockedMs p75. Then apply the remediation — defer, gate behind consent, or offload the vendor script — and watch the CrUX/RUM INP p75 for that route fall back under 200 ms over the following collection period. A concrete, observable pass looks like this:

  • The /rum/inp beacons for the regressed route attribute ≥ 60% of total blockedMs to one vendor key.
  • After remediation, that vendor’s blockedMs p75 drops sharply and the route’s INP p75 returns below 200 ms in the next CrUX window.
  • A fresh throttled DevTools Performance trace of the same interaction shows the previously dominant long task from that sourceURL is gone or shortened.

Reading the join correctly

The join itself has one subtlety that produces confident wrong answers: a long animation frame can contain several scripts, and the longest one is not always the one the interaction triggered. Check the invoker before concluding.

The longest script is not always the culprit One long animation frame contains three script entries. The first, at 30 milliseconds, has an invoker naming a click listener and is the one the interaction triggered. The second, at 90 milliseconds, has an invoker naming a timer callback and happened to overlap the interaction without being caused by it. The third, at 15 milliseconds, is a promise resolution. Choosing by duration alone would blame the timer; choosing by invoker correctly blames the click listener. one long-animation-frame 30 ms invoker: click 90 ms — longest, but unrelated invoker: setTimeout 15 ms invoker: promise sort by duration → you blame the timer, and the fix does nothing filter by invoker first, then sort → you blame the click listener, correctly Both scripts are real costs. Only one of them is this interaction's cost.

Common Pitfalls

  • Ranking on duration instead of totalDuration. A script’s raw execution time undercounts the forced layout and style recalculation it triggers. totalDuration is the true main-thread cost and the correct key for picking the worst offender.
  • Attributing to the first LoAF you find rather than the overlapping one. A slow interaction can span several animation frames. Test interval overlap against the event’s processingStart/processingEnd window and consider every overlapping frame, not just the nearest by start time.
  • Assuming LoAF is available everywhere. long-animation-frame is Chromium-only. On Safari and Firefox you fall back to coarser event timing without script names, so treat LoAF attribution as a Chromium-majority signal and keep the raw event p75 as your cross-browser ground truth.

Frequently Asked Questions

Why does the vendor's own dashboard show no regression?

Because it is measuring a different thing on a different population. Vendor dashboards typically report their script’s own load and initialisation timing, averaged across all customers and all devices — a number that can be entirely stable while the script’s effect on your interactions gets worse, for instance because a new autocapture routine runs on handlers it did not previously touch.

Averages hide it further. Your problem is at the seventy-fifth percentile on mid-tier devices; an average across a customer base weighted toward desktop traffic will not move measurably even when that percentile does. Bring the LoAF attribution to the vendor rather than the aggregate metric — a named script and a duration is a conversation they can act on.

How much traffic do we need before the p75 is trustworthy?

Enough interactions, not enough page views — and the two differ by an order of magnitude, since many sessions produce no qualifying interaction at all. As a rule of thumb, a few thousand interactions per segment per week gives a percentile stable enough that a genuine shift is distinguishable from noise; below that, treat a movement as a hypothesis to confirm rather than a finding.

Segment before you conclude, too. A p75 computed across desktop and mobile together will move whenever the traffic mix moves, which produces regressions that correlate with marketing campaigns rather than with anything you shipped.


Up: Attributing RUM Data to Third-Party Vendors