Injecting Custom Metrics via PerformanceObserver

The standard web-vitals library reports LCP, INP, and CLS, but it cannot tell you why INP was slow or which element was the LCP candidate. To gate on those root causes you need raw timeline entries — long tasks, long animation frames, element timing, and your own custom marks — captured with PerformanceObserver and beaconed alongside the vitals. This guide is part of the Custom Performance Beacons & RUM reference and covers capturing those custom metrics in the field, transmitting them, and asserting them in CI. Treat every ceiling here as a field P75 measured on mid-range Android (a Moto G-class device) over Fast 3G unless stated otherwise — that is the population where the tail actually hurts, and the number you calibrate against a synthetic run in the lab will differ from the field figure by design.

The trap is timing skew: an observer registered without buffered: true misses entries that fired before hydration, and a clock misaligned with your synthetic runner produces false budget violations when you correlate field and lab. A second trap is transport: entries that never leave the browser cannot be gated on, so the flush path has to survive a backgrounded tab. The implementation below avoids both, and it is intentionally small so it can load before your framework hydrates without adding to the very metrics it measures.

Metric Reference

These are the high-value entry types beyond the standard vitals, what they diagnose, and a representative field P75 ceiling to gate on. Each ceiling assumes mid-range mobile on Fast 3G at the 75th percentile; a desktop-on-cable P75 for the same metric typically lands 40–60% lower and deserves its own budget. Calibrate each against your own data per the parent reference before you enforce it.

Entry type What it captures Diagnoses Field P75 ceiling (mid-range mobile, Fast 3G)
longtask Tasks blocking the main thread > 50 ms Input delay, jank ≤ 200 ms total on the page
long-animation-frame (LoAF) Frames > 50 ms with script attribution INP root cause ≤ 200 ms per frame
event (Event Timing) Per-interaction processing time INP outliers ≤ 200 ms per interaction
element (Element Timing) Render time of elementtiming-tagged nodes LCP candidate timing ≤ 2500 ms
mark / measure (User Timing) Custom app milestones Route/feature timing per-mark budget (e.g. ≤ 800 ms to first product paint)

The timeline below shows why buffered: true is not optional: most of these entries fire during the load window, before a late-loading observer script has a chance to register. The buffer replays them.

Buffered replay recovers early entries A load timeline with entries firing before and after the observer registers, and a shaded window showing which ones buffered:true replays. Entry timeline across page load buffered:true replay window timeOrigin 0 ms observe() runs ~800 ms longtask 412 ms element (LCP) 640 ms loaf 1340 ms event 2100 ms event 3050 ms
The two entries left of the dashed line fired before the observer registered; without buffered:true they would be lost, hollowing out the early tail that matters most.

Diagnostic Steps

Confirm the entry types you intend to observe are actually supported and emitting before you wire the beacon — silent gaps are the usual cause of an empty P99.

  1. Verify support in the target browser's console, so you do not register an observer for a type that never fires:

    console.log(PerformanceObserver.supportedEntryTypes);
    // ["element","event","largest-contentful-paint","long-animation-frame","longtask","mark","measure","navigation","paint","resource"]
  2. Confirm long tasks are actually being recorded on the page under test:

    performance.getEntriesByType("longtask").forEach(e =>
      console.log(`longtask ${Math.round(e.duration)}ms @ ${Math.round(e.startTime)}ms`));
    // longtask 73ms @ 412ms
    // longtask 118ms @ 1340ms
  3. For cross-origin assets, check the response carries Timing-Allow-Origin so timing is not zeroed out — a missing header silently truncates element and resource timing, and you will spend an afternoon debugging an observer that is working perfectly.

Use the symptom-to-entry mapping below to decide which types are worth the payload cost for a given page. You rarely need all five; observe the ones that diagnose the vital you are actually failing.

Which entry type to observe A decision tree branching from the failing vital to the entry types that explain it. Which vital regressed at field P75? INP slow LCP slow Jank / high TBT Observe long-animation- frame + event Observe element timing Observe longtask
Start from the failing vital and observe only the entry types that explain it — this keeps the beacon payload small and the P75 signal focused.

Implementation

This module registers a single observer for the custom entry types, normalizes each into the same compact shape the vitals beacon uses, and transmits with sendBeacon so entries survive page unload. It aligns timestamps to performance.timeOrigin to prevent the clock skew that generates false violations when correlating with synthetic traces. Keeping every entry in the same { s, n, v, a, u } record shape means the aggregation store treats a custom longtask row exactly like an LCP row, which is what makes a single percentile query work across the whole set.

// custom-metrics-observer.js — load early, after the sampling decision
const ENDPOINT = "/rum/ingest";
const session = crypto.randomUUID();
const queue = [];

function record(name, value, attr) {
  queue.push({ s: session, n: name, v: Math.round(value), a: attr || "", u: location.pathname });
}

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    switch (entry.entryType) {
      case "longtask":
        record("longtask", entry.duration);
        break;
      case "long-animation-frame":
        // Attribute the slow frame to the worst script for triage.
        record("loaf", entry.duration, entry.scripts?.[0]?.sourceURL || "");
        break;
      case "event":
        if (entry.duration >= 40) record("event", entry.duration, entry.name);
        break;
      case "element":
        record("element", entry.renderTime || entry.loadTime, entry.identifier);
        break;
    }
  }
});

// buffered:true replays entries that fired before this code ran.
observer.observe({ type: "longtask", buffered: true });
observer.observe({ type: "long-animation-frame", buffered: true });
observer.observe({ type: "event", durationThreshold: 40, buffered: true });
observer.observe({ type: "element", buffered: true });

// Flush once, reliably, when the page is backgrounded or unloaded.
addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden" && queue.length) {
    navigator.sendBeacon(ENDPOINT, JSON.stringify(queue.splice(0)));
  }
});

The durationThreshold: 40 on event timing keeps trivial interactions out of the payload, and flushing on visibilitychange (rather than unload, which is unreliable on mobile) is the pattern that actually delivers on iOS Safari and backgrounded tabs. The path from entry to ingest is short but every hop can drop data, so it helps to see it laid out.

Custom metric beacon pipeline Entries flow from the observer callback into a normalized record, buffer in a queue, and flush once on visibilitychange via sendBeacon. From entry to ingested metric Observer callback longtask, loaf, event record() normalize to {s,n,v,a,u} queue[] buffer in memory visibilitychange: hidden navigator.sendBeacon() POST /rum/ingest aggregation store
One observer feeds a single normalized queue that flushes exactly once when the tab is hidden, so a backgrounded mobile session still delivers its tail entries.

If your queue can grow large on long-lived SPA sessions, pair this with the transport patterns in Batching Beacons With sendBeacon so a single flush does not exceed the 64 KB sendBeacon payload limit that most browsers enforce.

CI Gating Assertion

Once the custom metrics land in your aggregation store, gate on their field P75 the same way you gate vitals. This step queries the store and fails the build when the long-task or LoAF budget is breached, producing parseable per-metric output. Run it on a schedule rather than per-PR, because a field percentile needs a rolling window of real sessions to be stable — a 28-day window at P75 is a reasonable default for a site seeing at least a few thousand qualifying sessions a day.

# .github/workflows/custom-metric-gate.yml
name: Custom Metric Budget Gate
on:
  schedule:
    - cron: "0 6 * * *"
  workflow_dispatch:
jobs:
  custom-metric-gate:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - name: Assert custom field budgets
        env:
          RUM_QUERY_URL: ${{ secrets.RUM_QUERY_URL }}
        run: |
          curl -fsSL "$RUM_QUERY_URL?window=28d&pct=75&metrics=longtask,loaf,event" -o p75.json
          node -e '
            const b = require("./p75.json");
            const budgets = { longtask: 200, loaf: 200, event: 200 };
            let failed = false;
            for (const [m, max] of Object.entries(budgets)) {
              const ok = (b[m] ?? 0) <= max;
              console.log(`[CustomGate] ${m} P75=${b[m]} budget=${max} ${ok ? "PASS" : "FAIL"}`);
              if (!ok) failed = true;
            }
            process.exit(failed ? 1 : 0);
          '

The 200 ms ceilings here are the mid-range-mobile Fast 3G P75 figures from the reference table. If you gate desktop separately, split the query by device class and pass a second, tighter budget object — a desktop-on-cable P75 long-task budget nearer 120 ms is realistic. For the interaction-heavy event metric specifically, derive the ceiling from your own interaction mix rather than copying 200 ms wholesale, following Calculating INP Thresholds for Interactive Dashboards.

Avoiding Skew and Double-Counting

Two subtle failures make a working observer report wrong numbers. The first is clock skew: every duration you record is relative to performance.timeOrigin, which is per-document. If you ever mix a Date.now() wall-clock value into the same field as a monotonic entry.duration, your percentiles become meaningless. Keep every recorded v a monotonic duration from the Performance timeline and never blend the two clocks.

The second is double-counting from buffered: true. On a soft-navigated SPA where you re-run the observer setup per route, buffered replay can hand you entries you already recorded on the previous route. Scope the session id and the queue to the observer instance, deduplicate on (entryType, startTime), and only flush entries whose startTime is greater than the last route change. This keeps a single interaction from inflating your event P75. If your app relies heavily on client-side transitions, the sampling and boundary rules in RUM Sampling Strategies for High-Traffic Sites and the head-versus-tail decision in Head-Based vs Tail-Based RUM Sampling determine whether your custom-metric tail is even representative.

Verification

After deploying the observer, load an instrumented page, interact with it, then background the tab and confirm one beacon fires. In the store you should see longtask, loaf, and event rows for the session. The CI job's expected passing output is a clean per-metric log:

[CustomGate] longtask P75=140 budget=200 PASS
[CustomGate] loaf P75=165 budget=200 PASS
[CustomGate] event P75=120 budget=200 PASS

If a metric is absent from p75.json, the observer for that type either never registered or the entry was unsupported — re-run the diagnostic step. If P75 values look implausibly low, you are likely sampling too few sessions to populate the distribution; a field P75 computed from fewer than a few hundred sessions per route will swing wildly day to day, so revisit the sampling rate in the parent reference before you trust the number.

Frequently Asked Questions

Why use long-animation-frame instead of longtask?

Long Animation Frames (LoAF) supersede the older long-task API for INP diagnosis because each LoAF entry carries script attribution — the source URL and the function that blocked the frame — so you can gate on the specific offender rather than an anonymous 120 ms task. Observe both: longtask for broad coverage and long-animation-frame where supported for actionable attribution. Both feed the same pipeline in Custom Performance Beacons & RUM.

Do I need buffered:true on every observer?

Yes, for any entry type that can fire before your script runs — long tasks, paint, element timing, and LCP all do. buffered: true replays the entries the browser recorded before observation began, so you do not lose the early jank that often matters most. It has no effect on types that only fire after registration, so it is safe to set everywhere.

Should the custom-metric gate run per pull request?

No. These are field metrics from real sessions, so a per-PR run would query a distribution that predates the change under review. Run the gate on a daily schedule against a rolling 28-day P75 window, and use synthetic Lighthouse CI for the per-PR signal. That split keeps the lab gate fast and the field gate statistically stable on mid-range mobile at the 75th percentile.

How do I keep the beacon from inflating the metrics it measures?

Keep the observer module tiny and dependency-free, load it before your framework hydrates, and never process entries synchronously on the main thread beyond a cheap record() push. Flush once with sendBeacon on visibilitychange so there is no repeated network work during the session. If the queue grows on long SPA sessions, batch it per Batching Beacons With sendBeacon to stay under the 64 KB payload cap.