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.
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.
-
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"] -
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 -
For cross-origin assets, check the response carries
Timing-Allow-Originso 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.
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.
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.