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.

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.

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.

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.

Up: Attributing RUM Data to Third-Party Vendors