How to Send LoAF Attribution in RUM Beacons Without Blowing the Payload Budget

This guide takes the Long Animation Frames API out of the lab and into the RUM beacons and field data collection pipeline that decides whether INP passes at p75.

The failure scenario is familiar to anyone who has tried it naively: a team serialises every LoAF entry into the beacon, payloads swell to 40KB on heavy sessions, sendBeacon starts returning false because the browser's queued-payload limit (roughly 64KB per page) is exhausted, and the analytics warehouse fills with high-cardinality URL strings no one can group. The data is valuable precisely because it names scripts — but only a few rows per session are worth sending.

Beacon size by collection strategy (heavy session) Bar chart comparing beacon payload sizes for four LoAF collection strategies against a 4KB target. Beacon size by collection strategy (heavy session) Every frame 38KB Frames over 50ms 14KB INP frame 1.2KB INP frame + 2 worst frames 2.6KB 4KB target

Rapid Diagnosis

  • Check sendBeacon's return value. Log the boolean in a test session; false means the payload was rejected for size and the data silently vanished.
  • Measure payload size by percentile. Instrument JSON.stringify(payload).length and look at p95, not the average — long sessions on low-end devices are exactly where frames pile up.
  • Count distinct sourceURL values in the warehouse. Tens of thousands of distinct values usually means query strings, cache busters or blob URLs are leaking cardinality.
  • Audit for personal data. invoker strings can contain element IDs and URLs; query strings on script URLs can carry tokens.

Root Cause Analysis

1. Sending frames that cannot affect INP. Most long frames on a typical page happen during load, before any interaction. Only the frame that contains the INP interaction — and arguably the frame before it — explains the metric.

2. Sending every script row. A frame can list a dozen scripts. The top three by duration almost always cover more than 80% of script time; the rest is noise at the price of bytes.

3. Unnormalised URLs. Scripts served with ?v=123, signed CDN parameters, or blob: URLs make each session look unique. Grouping then fails, and per-row storage cost multiplies.

4. Sending at the wrong moment. Beacons fired on unload are unreliable on mobile; INP can also keep changing until the page is hidden. The beacon should be sent on visibilitychange to hidden, once.

Step-by-Step Resolution

1. Use the INP attribution frames, not your own observer buffer

The web-vitals attribution build already selects the frames that overlap the INP interaction. Start there.

javascript
import { onINP } from 'web-vitals/attribution';

let inpPayload = null;
onINP(({ value, attribution }) => {
  inpPayload = {
    inp: Math.round(value),
    target: attribution.interactionTarget,
    phase: dominantPhase(attribution),
    frames: (attribution.longAnimationFrameEntries || []).map(trimFrame),
  };
}, { reportAllChanges: true });
// trade-off: reportAllChanges keeps inpPayload current as worse interactions
// occur, at the cost of running the callback more often. That is cheap here
// because we only store, never send, inside the callback.

Expected outcome: payloads describe one interaction in depth instead of every frame shallowly.

2. Trim each frame to its top scripts and a few numbers

javascript
function normalizeUrl(u) {
  if (!u) return '(inline)';
  if (u.startsWith('blob:')) return '(blob)';
  try { const url = new URL(u); return url.origin + url.pathname; } catch { return '(invalid)'; }
}
function trimFrame(f) {
  return {
    d: Math.round(f.duration),
    b: Math.round(f.blockingDuration),
    r: Math.round(f.startTime + f.duration - f.renderStart),
    s: [...f.scripts].sort((a, b) => b.duration - a.duration).slice(0, 3).map((s) => ({
      u: normalizeUrl(s.sourceURL),
      f: (s.sourceFunctionName || '').slice(0, 60),
      t: s.invokerType,
      ms: Math.round(s.duration),
      l: Math.round(s.forcedStyleAndLayoutDuration),
    })),
  };
}
// trade-off: dropping sourceCharPosition saves bytes but removes the ability
// to source-map minified anonymous functions. Keep it for first-party origins
// if your bundles are minified and function names are mangled.

Expected outcome: around 300–500 bytes per frame, with the field names short enough to matter at scale.

What survives into the beacon Layers of a LoAF entry from full entry down to the trimmed beacon fields that are kept. What survives into the beacon Full LoAF entry 20+ fields per frame, every script with invoker strings INP-overlapping frames usually one or two frames per session Top 3 scripts each normalised URL, function, invoker type, duration, forced layout Beacon payload about 1-3KB, sent once on visibilitychange

3. Send once, on hide, with a size guard

javascript
addEventListener('visibilitychange', () => {
  if (document.visibilityState !== 'hidden' || !inpPayload) return;
  let body = JSON.stringify(inpPayload);
  if (body.length > 8000) {
    inpPayload.frames = inpPayload.frames.slice(0, 1);   // degrade, do not drop
    body = JSON.stringify(inpPayload);
  }
  if (!navigator.sendBeacon('/rum/inp', body)) {
    fetch('/rum/inp', { method: 'POST', body, keepalive: true });
  }
  inpPayload = null;
}, { capture: true });
// trade-off: sending once on the first hide loses interactions that happen
// after the user returns to the tab. For long-lived SPAs, send on every hide
// and de-duplicate server-side by a session id.

Expected outcome: delivery rates for INP attribution climb back above 95% of sessions on mobile.

4. Strip personal data and cap cardinality server-side

Even after client trimming, normalise again at ingest: allow-list origins you recognise, bucket unknown third-party origins under other, and drop invoker strings that contain digits longer than eight characters (likely IDs). Then store scripts as a child table keyed by session so you can aggregate sum(ms) by u, f.

sql
-- Top scripts in INP frames over the last 7 days, p75-failing sessions only.
SELECT s.u, s.f, s.t, count(*) AS sessions, approx_quantiles(s.ms, 100)[OFFSET(75)] AS p75_ms
FROM rum_inp i, UNNEST(i.frames) f, UNNEST(f.s) s
WHERE i.inp > 200 AND i.ts > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
GROUP BY 1, 2, 3 ORDER BY sessions DESC LIMIT 20;
-- trade-off: filtering to inp > 200 shows what breaks failing sessions but
-- hides scripts that are slow everywhere. Run the query unfiltered too.

Field attribution round trip Sequence from the browser recording an interaction to the warehouse producing a ranked script report. Field attribution round trip Browser Collector Warehouse Engineer beacon on hide (2KB) normalised rows top-scripts query ranked owners

Verification

Compare a week before and after: the median beacon should be a few kilobytes at most, sendBeacon failures should be near zero, and the "top scripts in INP frames" query should return a short list where the first five rows account for most failing sessions. Cross-check one of those rows in the lab with the method in finding slow scripts with LoAF script attribution; the field and lab should agree on the culprit even if the milliseconds differ.

Designing the Schema for Questions You Will Actually Ask

Beacon data is only useful if the warehouse can answer the questions engineers bring to it. Three questions come up repeatedly, and the schema should make each a single query.

"Which script is responsible for most failing interactions this week?" Needs the script rows joined to the session's INP value and a time column. Store scripts as a repeated child record of the INP event rather than as a JSON string; string blobs force a parse in every query and defeat columnar compression.

"Did release 4.12 make things worse?" Needs a build or release identifier on every beacon. Without it, a regression introduced on Tuesday and a traffic-mix change on Wednesday look identical. Add the identifier from a <meta> tag rendered at build time, not from a runtime fetch.

"Is this a device problem or a code problem?" Needs device memory, hardware concurrency and effective connection type captured with the beacon — the dimensions covered in segmenting RUM by device and connection. A script that costs 40ms on 8-core devices and 220ms on 4-core devices is a different conversation from one that is slow everywhere.

Keep the field names short in the beacon and expand them at ingest. Bytes on the wire matter on a mobile network; column names in the warehouse should be self-explanatory for the person running the query six months from now.

INP event schema that answers the common questions Layers of a stored INP event, from the session record through the interaction to frames and script rows. INP event schema that answers the common questions Session release id, device memory, cores, connection type, page template INP interaction value, target selector, dominant phase, timestamp Frames duration, blocking duration, render tail Script rows normalised URL, function, invoker type, ms, forced layout ms

FAQ

Should I sample LoAF collection separately from the rest of RUM?

Usually not. INP attribution is most valuable on exactly the sessions that fail, and independent sampling discards some of them at random. If volume is a concern, sample whole sessions consistently — the approach in sampling RUM data without biasing p75 — and keep attribution on every sampled session.

Is it safe to send script URLs to an analytics vendor?

Script URLs themselves are rarely sensitive, but query strings and inline invoker values can be. Normalising to origin plus path and allow-listing origins removes almost all risk. Treat the field as you would a page URL in your privacy review.