How to Find the Script Behind a Slow Interaction with LoAF Attribution

This guide applies the Long Animation Frames API to the most common question it answers, within the wider Core Web Vitals & Measurement section: which code made this interaction miss the 200ms budget?

The scenario: RUM says INP is 280ms at p75 on your product listing page, and the Performance panel shows a dense wall of yellow when you click a filter. Every script on the page has an owner, but nobody can say whose code is in that wall. LoAF's scripts array can, provided you read it the right way — ranked by duration, grouped by source, and resolved through source maps.

From raw frame to owned ticket Four steps from collecting long animation frames to producing a ranked, owned list of slow scripts. From raw frame to owned ticket Collect frames interactive and over 50ms Flatten scripts one row per script entry Group and rank by URL and function Source-map file

Rapid Diagnosis

  • Confirm the frame is interactive. In the console, read the frame's firstUIEventTimestamp; zero means no input was processed in it and it is not your INP frame.
  • Count the scripts. One dominant script entry points to a single handler; ten mid-sized entries point to a fan-out or several listeners on one event.
  • Check sourceFunctionName. Blank names with a sourceCharPosition of -1 mean a cross-origin script without CORS — you can identify the vendor but not the function.
  • Compare duration with forcedStyleAndLayoutDuration. If layout is most of a script's time, the code is not slow; its DOM access pattern is.
  • Note the invokerType. event-listener is your handler; resolve-promise is work chained after an await; classic-script is a script evaluating mid-interaction.

Root Cause Analysis

1. A single heavy listener. One event-listener entry exceeds 50ms. The handler does synchronous work — sorting, filtering, serialising — before returning. This is the easiest case to fix and the most common in first-party code.

2. A promise chain that outlives the listener. The listener itself is 5ms, but resolve-promise entries from the same source file total 120ms. The code awaited a fetch or a microtask and then did the expensive part. Developers miss this because the handler looks innocent in review.

3. A vendor script evaluating on the same frame. A classic-script or user-callback entry from another origin coincides with the input. The user's tap was simply unlucky — but on a page where a tag fires on every scroll, it is unlucky for many users.

4. The framework dispatcher masking the component. invoker reads DIV#__next.onclick or similar for every interaction because React delegates all events at the root. Grouping by invoker lumps everything together; grouping by sourceURL plus sourceCharPosition separates components.

Script time inside one failing filter interaction Bar chart of four script entries from one long frame, showing a promise continuation dominating a small listener. Script time inside one failing filter interaction filters.js resolve-promise 118ms gtm.js user-callback 46ms react-dom commit 38ms filters.js onClick 6ms 50ms per-task budget

Step-by-Step Resolution

1. Flatten every interactive frame into script rows

Collect long frames with an observer registered early, then flatten them. A flat table is far easier to group than nested entries.

javascript
function scriptRows(frames) {
  return frames
    .filter((f) => f.firstUIEventTimestamp > 0 && f.duration > 50)
    .flatMap((f) => f.scripts.map((s) => ({
      frame: Math.round(f.startTime),
      src: s.sourceURL || '(inline)',
      fn: s.sourceFunctionName || '(anonymous)',
      pos: s.sourceCharPosition,
      type: s.invokerType,
      invoker: s.invoker,
      ms: s.duration,
      layoutMs: s.forcedStyleAndLayoutDuration,
    })));
}
// trade-off: dropping frames without input hides the frame immediately before
// the tap, which is the one that inflates input delay. Include it when input
// delay, not processing, dominates your INP breakdown.

Expected outcome: a list of perhaps 20–60 rows per reproduction instead of a flame chart with thousands of nodes.

2. Group by source and function, rank by total time

Ranking by total time across frames, not by single worst entry, finds the code that costs the most interactions overall.

javascript
function rank(rows) {
  const groups = new Map();
  for (const r of rows) {
    const key = `${r.src}#${r.fn}@${r.pos}`;
    const g = groups.get(key) || { key, type: r.type, total: 0, worst: 0, count: 0, layout: 0 };
    g.total += r.ms; g.layout += r.layoutMs; g.count += 1;
    g.worst = Math.max(g.worst, r.ms);
    groups.set(key, g);
  }
  return [...groups.values()].sort((a, b) => b.total - a.total).slice(0, 10);
}
console.table(rank(scriptRows(frames)));
// trade-off: total time favours code that runs on every interaction. A rare
// but catastrophic handler (one 600ms checkout click) can rank low here — sort
// by `worst` as a second pass before deciding what to ship.

Expected outcome: the top two or three keys usually account for over half of all long-frame script time.

3. Resolve positions through source maps

sourceCharPosition is a character offset into the served file. Convert it to a line and column, then run it through the source map to reach the original component.

javascript
// node resolve-position.mjs dist/assets/filters-3f9a.js 48211
import { readFileSync } from 'node:fs';
import { SourceMapConsumer } from 'source-map';
const [file, offset] = process.argv.slice(2);
const code = readFileSync(file, 'utf8').slice(0, Number(offset));
const line = code.split('\n').length;
const column = code.length - code.lastIndexOf('\n') - 1;
const map = JSON.parse(readFileSync(`${file}.map`, 'utf8'));
await SourceMapConsumer.with(map, null, (c) =>
  console.log(c.originalPositionFor({ line, column })));
// trade-off: this needs the exact build's .map file. Keep source maps as private
// build artefacts per release; resolving against a later build gives a
// confidently wrong line.

Expected outcome: each top-ranked row gains a component name and an owning team, which is what turns a metric into a ticket.

4. Apply the fix the invoker type implies

Expected outcome: fixing the top-ranked group typically removes 80–150ms from the slowest interaction on mid-tier mobile.

Invoker type to first fix Mapping of each LoAF invoker type to the first fix to try and how much INP it typically recovers. Invoker type to first fix invokerType First fix Typical gain event-listener Yield inside the handler 60-150ms resolve-promise Yield after the await 40-120ms classic-script Defer vendor to idle varies by tag user-callback Throttle the timer or rAF 20-80ms

Verification

Re-run the same scripted interaction and regenerate the ranked table. The fixed key should drop out of the top three, and the interaction's longest frame should fall below 100ms in a 4x-throttled lab run. Then ship and compare field data: INP processing duration for the affected interaction target should fall, and the share of INP beacons naming the fixed function should shrink to near zero within a release cycle.

javascript
// Compare before/after script totals for one key across two lab runs.
const before = 182, after = rank(scriptRows(frames)).find((g) => g.key.includes('applyFilters'))?.total ?? 0;
console.log(`applyFilters: ${before}ms -> ${Math.round(after)}ms`);
// trade-off: a single lab run is noisy (±15%). Run five and compare medians
// before declaring victory or failure.

Reading Attribution on Framework-Heavy Pages

Single-page applications blur the line between "a script" and "a component", and the raw rows need interpretation before they become tickets.

React and Preact. Event delegation means invoker is the root container for almost every interaction, and the expensive event-listener entry is React's dispatcher in react-dom. The useful signal is the next entry — commonly a resolve-promise or a second event-listener from your own chunk — and sourceCharPosition within the dispatcher is meaningless. Combine LoAF with the React Profiler's commit timings to find the component; LoAF tells you whether the commit or the handler is the larger cost.

Vue and Nuxt. Vue binds listeners on the actual elements, so invoker is usually informative (BUTTON.apply-filter.onclick). The cost frequently appears as a resolve-promise entry from the scheduler flushing watchers after the handler — the reactive work, not the handler body.

Angular with zone.js. Patched APIs wrap every callback, so sourceURL points at zone.js or the polyfills chunk for many entries. Resolve with function names rather than URLs, or test against a zoneless build where attribution points at application code directly.

Micro-frontends. Several independently built bundles on one page each produce their own chunk URLs. Group by a path prefix per micro-frontend (/mf/checkout/, /mf/search/) before ranking; it maps directly to team ownership and avoids arguing over shared vendor chunks.

In every case, store the build ID alongside the rows so a release that changes chunk names does not split one function's history into two keys.

FAQ

Why are some script entries missing from the frame?

The specification only reports scripts above a minimum duration (a few milliseconds), and only those that are attributable to a script execution — browser-internal work and rendering are reported as frame phases instead. A frame built from very many tiny callbacks may therefore show a long duration with few script rows; that shape usually means a reactive store fan-out rather than one slow function.

Can I get function names for third-party scripts?

Only if the script is loaded with crossorigin="anonymous" and the vendor responds with an Access-Control-Allow-Origin header. Without both, you still get the sourceURL, which is normally enough to know which vendor to defer. Adding crossorigin to a script whose server does not send CORS headers will make the load fail, so verify the header first.