Building P75/P99 Aggregation Pipelines

A raw beacon stream is millions of individual numbers; a budget gate needs one number — the P75 — and one tripwire — the P99. The pipeline between them must compute percentiles that are accurate at the tail, cheap to update incrementally, and mergeable across time windows. This guide is part of the Custom Performance Beacons & RUM reference and covers the percentile-method tradeoff, how a sketch keeps the tail honest, rolling windows, storage, and the final CI assertion.

The naive approach — store every value and sort on read — is exact but does not scale and cannot be rolled up. Production pipelines use a mergeable sketch (t-digest or fixed-bucket histograms) so each ingestion window produces a small structure that can be combined into any larger window without re-reading raw events. Get that shape right once and the same stored digests answer a 1-day regression question, a 7-day trend, and the 28-day reading you actually gate on — the same window Chrome uses to assess Core Web Vitals in the field.

RUM percentile aggregation pipeline A left-to-right data flow from raw beacons through mergeable digests to the CI gate. From raw beacons to one gated number Raw beacons millions of values t-digest per window Digest store 1-5 KB each Merge on read P75 gate P99 watch Raw data is retained only long enough to spot-check the sketch; digests persist long-term.
The pipeline collapses millions of beacons into small mergeable digests, then reads a single P75 for the gate and a P99 for the tail watch.

Percentile Method Comparison

The right method depends on tail accuracy, mergeability, and storage. The table compares the three you will choose between.

Method P99 accuracy Mergeable? Storage/window Best for
Exact sort Exact No All raw values Small volume, ad-hoc
Fixed-bucket histogram Bounded by bucket width Yes (add counts) ~dozens of ints Prometheus-style, known range
t-digest sketch High, adaptive at tail Yes (merge digests) ~1–5 KB High volume, accurate P99

For Core Web Vitals, t-digest is the default: it spends its resolution where the percentiles you gate on live (the upper tail), stays around a few KB per window regardless of session count, and merges cleanly so a daily digest rolls up into a 28-day reading without touching raw events. Fixed-bucket histograms are the pragmatic runner-up when your metric has a well-known range and you already run Prometheus — for LCP you might allocate 100 ms buckets from 0 to 8000 ms, which caps the P75 estimate error for mid-range mobile on Fast 3G at half a bucket, roughly 50 ms. Exact sorting is reserved for a single small window during verification, never for the live gate.

How t-Digest Concentrates Resolution at the Tail

The reason a t-digest beats a uniform histogram for percentile gating is where it puts its accuracy. A t-digest groups nearby values into centroids whose allowed weight is largest near the median and shrinks toward the extremes. That means the median region is summarised coarsely — you do not gate on the exact median — while the P90-to-P99 band is described by many small centroids, so the number you actually enforce is faithful. A budget that fails a build when LCP P75 crosses 2500 ms for mid-range mobile on Fast 3G needs that reading stable to within a few milliseconds run-to-run, and the sketch delivers it at a fraction of the storage.

t-digest resolution concentrates at the tail Circles representing centroids shrink and pack tighter toward the slower LCP tail. Resolution is spent where the percentiles live P75 P99 faster LCP slower LCP (the tail) wide centroids, coarse near the median narrow centroids, high resolution
Each circle is a centroid; they shrink and cluster toward the tail so the P90-to-P99 band you gate on stays sharp.

Diagnostic Steps

Confirm the aggregation is faithful before you gate on it — a histogram whose buckets are too wide, or a digest fed unsorted garbage, produces a confident wrong number.

  1. Spot-check a computed percentile against a brute-force sort on one window of raw data:

    curl -fsSL "$RUM_QUERY_URL?window=1d&metric=LCP&raw=true" | \
      jq -s 'sort_by(.v) | .[(length*0.75)|floor].v'
    # 2380  -> compare against the pipeline's reported P75 for the same window
  2. Verify the digest is mergeable by checking a 7-day rollup equals the merge of seven daily digests, not a re-sort:

    curl -fsSL "$RUM_QUERY_URL?window=7d&metric=LCP&pct=99"   # rolled-up
    # { "LCP": 4480 }
  3. Confirm the tail has enough samples to trust. A P99 computed from a segment with only a few hundred kept sessions is dominated by a handful of outliers and will jitter run-to-run. When a segment is thin, widen the window or interpolate — see Interpolating Percentiles From Sparse Samples — rather than gating on a noisy number.

If the sketch and the brute-force sort disagree by more than a few percent on the same window, the compression or the merge is wrong; do not ship the gate until they converge.

Implementation

This pipeline ingests a window of raw beacons into a t-digest per metric and segment, persists the serialized digest, and answers percentile queries by merging the digests covering the requested window. It uses a t-digest library so the sketch math is correct and mergeable.

// aggregate.js — run per ingestion window (e.g. hourly) per metric+segment
import TDigest from "tdigest";

// 1. Build a digest from this window's raw beacon values.
export function buildDigest(values) {
  const td = new TDigest();
  for (const v of values) td.push(v);
  td.compress();
  return td.toArray();          // serialized centroids, ~1-5 KB
}

// 2. Answer a percentile query by merging the digests in the window.
export function percentile(digestRows, p) {
  const merged = new TDigest();
  for (const row of digestRows) merged.push_centroid(row); // mergeable rollup
  merged.compress();
  return Math.round(merged.percentile(p)); // p in [0,1], e.g. 0.75 or 0.99
}

Segment on the dimensions your budgets are written against — at minimum metric, device_class, and route — because a single blended P75 hides a mobile regression behind fast desktop traffic. Keep the segment cardinality bounded: one digest per metric per device class per route template is fine, but never key on a raw URL or a user id or the digest count explodes. For storage, a columnar store makes the rollup a one-line aggregate.

-- rollup query: read the digests covering the gate window, not raw events.
-- (Engines like ClickHouse expose quantileTDigest natively; this is the shape.)
SELECT metric,
       device_class,
       route,
       quantileTDigestMerge(0.75)(digest) AS p75,
       quantileTDigestMerge(0.99)(digest) AS p99
FROM rum_digests
WHERE ts >= now() - INTERVAL 28 DAY
GROUP BY metric, device_class, route;

Window by ingestion time and roll up on read so a single slow hour does not permanently skew the gate, and so you can answer 1-day, 7-day, and 28-day questions from the same stored digests. Retain raw beacons for a short window (for example, 7 days) for the spot-check above, then keep only the digests long-term — they are orders of magnitude smaller.

Daily digests merge into the gate window Small daily digest blocks combine through a merge step into one 28-day P75 and P99 reading. Roll up on read, never re-sort raw events Daily t-digests (mergeable, 1-5 KB each) D1 D2 D3 D4 D5 D6 D7 quantileTDigestMerge 28-day merged reading P75 hard gate + P99 tail watch
Because digests merge, the 28-day gate reading is assembled from stored daily digests in one query — no raw beacon is touched.

CI Gating Assertion

The gate reads the rolled-up P75 and P99 straight from the pipeline and fails the build when either breaches budget — P75 as the hard gate, P99 as a tail tripwire surfaced in the log. The budgets below are expressed for mobile field traffic: LCP P75 at 2500 ms and P99 at 4500 ms, INP P75 at 200 ms and P99 at 500 ms, and CLS P75 at 0.10 (encoded as 100 milli-units) — the ceilings you set in Percentile-Based Threshold Tuning.

# .github/workflows/percentile-gate.yml
name: P75/P99 Budget Gate
on:
  schedule:
    - cron: "0 6 * * *"
  workflow_dispatch:
jobs:
  percentile-gate:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - name: Assert P75/P99 from aggregation pipeline
        env:
          RUM_QUERY_URL: ${{ secrets.RUM_QUERY_URL }}
        run: |
          curl -fsSL "$RUM_QUERY_URL?window=28d&pct=75,99&metrics=LCP,INP,CLS" -o pct.json
          node -e '
            const d = require("./pct.json");
            const p75Budget = { LCP: 2500, INP: 200, CLS: 100 };
            const p99Budget = { LCP: 4500, INP: 500, CLS: 250 };
            let failed = false;
            for (const m of Object.keys(p75Budget)) {
              const okP75 = d[m].p75 <= p75Budget[m];
              const okP99 = d[m].p99 <= p99Budget[m];
              console.log(`[PctGate] ${m} P75=${d[m].p75}/${p75Budget[m]} ${okP75?"PASS":"FAIL"} | P99=${d[m].p99}/${p99Budget[m]} ${okP99?"OK":"WATCH"}`);
              if (!okP75) failed = true; // P75 gates; P99 only warns
            }
            process.exit(failed ? 1 : 0);
          '

Verification

The passing signal is every metric under its P75 budget, with P99 shown for tail awareness:

[PctGate] LCP P75=2310/2500 PASS | P99=4280/4500 OK
[PctGate] INP P75=180/200 PASS | P99=470/500 OK
[PctGate] CLS P75=80/100 PASS | P99=240/250 WATCH
LCP P75 and P99 against mobile budgets Grouped bars comparing measured LCP P75 and P99 to their budget ceilings, all below the line. LCP percentiles vs mobile budget 0 1000 2000 3000 4000 5000 2310 2500 P75 4280 4500 P99 measured budget
Bar height is LCP in milliseconds for mid-range mobile on Fast 3G; both the P75 and the P99 sit under their budget ceilings, so the gate passes with tail headroom.

A WATCH on P99 with a passing P75 is the early signal of a tail regression — investigate before it pulls the median up and trips the gate. It usually means a slice of the tail is drifting: a slow third-party on a subset of sessions, or a device class you would only see if you slice the field traffic the way Head-Based vs Tail-Based RUM Sampling describes, keeping the slow sessions the digest needs. If the pipeline's P75 disagrees with the brute-force sort from the diagnostic step by more than a few percent, the digest compression or merge is wrong; rebuild from raw before trusting the gate. Calibrate the budgets themselves against Percentile-Based Threshold Tuning.

Frequently Asked Questions

Why t-digest instead of just storing every value and sorting?

Exact sorting is correct but does not scale and cannot be rolled up: a 28-day P75 would require re-reading every raw beacon for the month. A t-digest is a small mergeable sketch — a few KB per window — that stays accurate at the tail where your P99 lives, and merges so daily digests combine into any longer window without touching raw events. Keep raw data only briefly to spot-check the sketch, as shown in Custom Performance Beacons & RUM.

How long a window should the gate read?

A 28-day rolling window matches the Core Web Vitals assessment period and smooths day-of-week traffic shifts, so it is the right default for the gate on mobile field traffic. Keep shorter windows — 1-day and 7-day — available from the same digests for faster regression detection, but block merges on the 28-day reading so a single noisy day does not fail the build. Tune the thresholds per Percentile-Based Threshold Tuning.

How do I get a trustworthy P99 for a low-traffic segment?

A P99 built from a few hundred kept sessions is dominated by a handful of outliers and jitters run-to-run, so gating on it produces flaky builds. Widen the window until the tail has enough samples, merge thin segments into a parent segment, or interpolate as covered in Interpolating Percentiles From Sparse Samples. For very thin routes, gate on P75 only and treat P99 as informational.

Should P99 fail the build or only warn?

Gate the build on P75 and treat P99 as a WATCH signal in the log, not a hard failure. The P99 is noisier and reacts to a small slice of slow sessions, so making it a blocker produces false reds; instead let a P99 breach flag a tail regression to investigate before it drags the P75 up. Once your sampling reliably keeps enough slow sessions you can promote P99 to a soft gate on your slowest route.

If I use fixed-bucket histograms instead, how wide should the buckets be?

Size buckets so half a bucket width is smaller than the precision your budget needs. For LCP on mid-range mobile over Fast 3G, 100 ms buckets from 0 to 8000 ms cap the P75 estimate error at roughly 50 ms, which is tight enough for a 2500 ms ceiling. Use narrower buckets in the P90-to-P99 band if you also gate on the tail, since uniform buckets spend equal resolution everywhere while a t-digest concentrates it where you need it.