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.

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. The implementation below avoids both.

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. Calibrate each against your own data per the parent reference.

Entry type What it captures Diagnoses Field P75 ceiling
longtask Tasks blocking the main thread > 50 ms Input delay, jank ≤ 200 ms total
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
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

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.

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.

// 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.

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.

# .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);
          '

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; revisit the sampling rate in the parent reference.

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.