Choosing Between P75 and P90 Budget Targets

P75 is the default everyone reaches for, but it is not always the right contract: a checkout flow where the slowest tenth of users abandons the cart needs P90, and an enterprise SLA that names a tail figure needs P95 or P99. Picking the percentile is a deliberate trade between coverage — how many users the budget protects — and cost — how many samples it takes to estimate and how often it flaps. This guide is part of the Percentile-Based Threshold Tuning reference, and it gives you a decision framework, a spread diagnostic, and a configurable assertion so the choice is data-driven rather than habitual.

The rule is simple to state and harder to apply: raise the percentile when the business cost of a slow tail is high, and lower it when your sample size or noise budget cannot support a stable estimate. P75 covers three in four users cheaply and stably; each step up to P90, P95, P99 buys more coverage but demands exponentially more samples and tolerates less noise. Chase the tail too hard and the gate becomes a random-number generator that blocks clean pull requests on background-tab stalls no user ever felt; stay too shallow and you ship regressions that only hurt the quarter of users who matter most.

Coverage vs Cost by Percentile

Every step up the percentile ladder protects more users, but the sample floor beneath a stable estimate grows faster than the coverage does. The reason is structural: to pin down P99 you need enough observations that roughly one in a hundred lands above your estimate, so a lab run of nine Lighthouse passes literally cannot express a P99 — there is no ninety-ninth data point. The table below pairs each percentile with the minimum sample it needs before the number stops wandering between runs.

Percentile Users covered Min samples for stability Noise sensitivity Use when
P75 75% ~20 lab / ~1k field Low Default for typical timing metrics (LCP, INP) and content routes
P90 90% ~50 lab / ~3k field Medium Rare-but-severe metrics (CLS), revenue-adjacent flows
P95 95% ~200 lab / ~10k field High Checkout, payment, sign-up — tail abandonment is costly
P99 99% field only / ~50k Very high Contractual SLAs that name a tail latency figure

Two forces set the floor in column three: estimating a higher percentile means fewer observations sit beyond it, so each one moves the estimate more, and the slow tail is exactly where outliers cluster. That is why a P99 belongs to large field datasets, never to a handful of CI runs. The bar chart makes the cost curve concrete — note that it is drawn on a log scale, because a linear axis would flatten P75 and P90 into invisible slivers next to P99.

Sample cost by percentile The minimum number of field sessions needed to estimate each percentile grows from about a thousand at P75 to fifty thousand at P99. Minimum field sessions for a stable estimate ~1k ~3k ~10k ~50k P75 P90 P95 P99 Percentile target (log-scaled bar heights)
Each step up the ladder roughly triples the field sample you need before the estimate stops flapping between runs.

A Decision Framework

Do not pick a percentile by taste. Walk two questions in order: is this route high-stakes, and can your data support the stricter number? A route is high-stakes when a slow tail costs revenue directly (checkout, payment, sign-up) or when the metric is rare-but-severe, meaning most sessions are fine but the few bad ones are very bad — layout shift and long interaction latency both behave this way. If neither is true, stay at P75 and spend your noise budget elsewhere. If the route is high-stakes, only then check whether you have the samples to hold P90 or higher without flapping. When you have the appetite for a stricter gate but not the sample volume, the fix is often to interpolate rather than to lower the target — see Interpolating Percentiles From Sparse Samples for the technique.

Percentile decision tree A two-question decision tree that routes each metric to P75, a keep-and-grow state, or P90 and above. Choose gating percentile High-stakes route? No Gate at P75 cheap and stable Yes Sample meets P90 floor? No Keep P75, grow the sample Yes Gate at P90 P95/P99 for an SLA
Only high-stakes routes with enough samples earn a stricter percentile; everything else stays at the cheap, stable P75.

Diagnostic Steps

Let your own data tell you whether stepping up the percentile is worth it. If P75 and P90 are close, the extra coverage is nearly free; if they diverge sharply, the higher percentile is both more meaningful and more fragile.

  1. Compute the spread for one metric. Pull the raw values and print P75, P90, and P95 side by side.

    curl -s "$RUM_API/lcp?route=/checkout&window=14d" \
      | jq '[.sessions[].lcp] | sort | {p75: .[(length*0.75)|floor],
             p90: .[(length*0.90)|floor], p95: .[(length*0.95)|floor]}'

    Expected output, e.g.: { "p75": 2380, "p90": 3120, "p95": 4010 } — for mid-range mobile on Fast 3G, a P75 LCP of 2380 ms with a P90 of 3120 ms is a wide gap that signals a heavy tail worth gating at P90.

  2. Quantify the gap. A spread under ~20% between P75 and P90 means P75 already represents the population well; over ~40% means a meaningful slow cohort hides above P75 and a stricter percentile is justified for a high-stakes route. In the example above the gap is (3120 − 2380) / 2380 ≈ 31%, which sits in the grey zone: promote to P90 if the route is revenue-critical, leave it at P75 if it is not.

  3. Check sample sufficiency. Confirm the session count clears the floor in the table for the percentile you are considering; if it does not, the higher percentile will flap and you should stay at P75. Sourcing that count reliably is the job of a P75/P99 aggregation pipeline rather than a single query.

The diagram below shows why the gap matters. On a right-skewed LCP distribution the percentile markers march up the tail, and the distance between them is the spread you measured in step two.

LCP distribution with percentile markers A right-skewed histogram of LCP with the P75, P90, and P95 markers spaced further apart as they climb the slow tail. LCP distribution with P75, P90, P95 markers P75 2380 P90 3120 P95 4010 LCP (ms), mid-range mobile on Fast 3G
The markers spread out as they climb the tail — a wide P75-to-P90 gap is the visual signature of a route worth gating higher.

Worked Example: Checkout vs Blog

Take two real routes from the same site. The /checkout flow, measured on mid-range mobile on Fast 3G, showed a P75 LCP of 2380 ms and a P90 of 3120 ms — a 31% spread — over roughly 8,000 field sessions in a fourteen-day window. That comfortably clears the ~3k P90 field floor, the route is revenue-critical, and the tail is where cart abandonment lives, so it earns a P90 gate at a 3000 ms ceiling. The /blog index, by contrast, had a P75 LCP of 2450 ms and a P90 of 2720 ms on the same device profile — an 11% spread — where the tail is a handful of readers on flaky connections and no revenue rides on them. It stays at P75 with a 2800 ms ceiling; promoting it to P90 would only add flakiness for coverage nobody would notice.

The device profile is not a footnote. Desktop on cable and mid-range mobile on Fast 3G produce different distributions and different tails, so the percentile choice and the ceiling both belong to a specific profile. If you gate a single blended number you inherit whichever cohort dominates your traffic mix; splitting them is the subject of Mobile vs Desktop Budget Divergence.

Implementation

Make the percentile a per-route, per-metric parameter so the choice lives in config and the evaluator stays generic.

// scripts/percentile-budget.js
function percentile(values, p) {
  const v = [...values].sort((a, b) => a - b);
  const rank = (p / 100) * (v.length - 1);
  const lo = Math.floor(rank), hi = Math.ceil(rank);
  return lo === hi ? v[lo] : v[lo] + (rank - lo) * (v[hi] - v[lo]);
}

const MIN_SAMPLES = { 75: 20, 90: 50, 95: 200, 99: 50000 };

function evaluate({ metric, percentile: p, max, level }, values) {
  if (values.length < (MIN_SAMPLES[p] || 20)) {
    return { metric, status: "WARN", reason: "insufficient-samples" };
  }
  const value = Math.round(percentile(values, p) * 1000) / 1000;
  const breached = value > max;
  return { metric, percentile: p, value, max,
    status: breached ? (level === "error" ? "FAIL" : "WARN") : "PASS" };
}

module.exports = { percentile, evaluate };
{
  "/checkout": {
    "lcp": { "metric": "lcp", "percentile": 90, "max": 3000, "level": "error" },
    "inp": { "metric": "inp", "percentile": 90, "max": 250,  "level": "error" }
  },
  "/blog": {
    "lcp": { "metric": "lcp", "percentile": 75, "max": 2800, "level": "error" }
  }
}

The checkout route gates at P90 because the spread diagnostic showed a heavy tail and the route is revenue-critical; its 3000 ms P90 LCP ceiling and 250 ms P90 INP ceiling both assume mid-range mobile on Fast 3G. The blog stays at P75 with a 2800 ms LCP ceiling on the same profile, where coverage is cheap and the tail is harmless. Keeping MIN_SAMPLES in the same module means a route can never silently gate on a percentile its data cannot support — the evaluator downgrades to a WARN instead of inventing a number.

CI Gating Assertion

The job evaluates each route at its configured percentile and fails only on an error-level breach.

# .github/workflows/percentile-choice-gate.yml
name: Percentile Choice Gate
on:
  pull_request:
    branches: [main]
jobs:
  percentile-gate:
    runs-on: ubuntu-latest
    timeout-minutes: 18
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "20", cache: "npm" }
      - run: npm ci && npm run build
      - name: Collect runs
        run: npx lhci collect --numberOfRuns=9 --url=http://localhost:8080/checkout
      - name: Evaluate at configured percentile
        run: node ./scripts/percentile-budget.js
              --reports .lighthouseci --config ./config/route-percentiles.json

Note numberOfRuns=9 — a P90 needs more runs than the five that suffice for P75, because a P90 over nine lab passes is interpolated between the eighth and ninth sorted values rather than pulled from the tail directly. If nine runs still leave the P90 jittering more than your regression threshold, the honest fix is a field percentile, not a louder lab gate; the trade-off is covered in Statistical Noise and Flakiness Reduction. Wire the same evaluator into Automated Regression Detection so a route trending toward its chosen percentile ceiling alerts before it breaches.

Verification

  • The job log prints one verdict per metric, e.g. { metric: 'lcp', percentile: 90, value: 2910, max: 3000, status: 'PASS' } — confirm the percentile field matches the route's intended choice, not a default 75.
  • Routes with too few collected runs report status: 'WARN', reason: 'insufficient-samples' instead of a number; raise numberOfRuns until they produce a value, or move that metric to a field percentile.
  • Re-run the spread diagnostic monthly; if a P75 route's P75-to-P90 gap widens past 40%, promote it to P90 and re-baseline so the new ceiling reflects the tighter contract rather than the old distribution.

Frequently Asked Questions

When is P90 worth the extra cost over P75?

When the slow tail carries real business risk and your sample supports it. If the route is revenue-critical (checkout, sign-up) or the metric is rare-but-severe (CLS), and the P75-to-P90 spread in your data exceeds about 40 percent, P90 protects a meaningful cohort that P75 ignores. If the spread is small or you lack the samples, the extra coverage is not worth the added flakiness.

Why not just gate everything at P99 for maximum coverage?

Because P99 is dominated by outliers and needs tens of thousands of field sessions to estimate stably — it will flap constantly on CI-scale samples and fail builds on background-tab stalls nobody felt. Reserve P99 for contractual SLAs measured against large field datasets, not lab gates.

How many samples do I need before P90 stops flapping?

Roughly 50 lab runs or about 3,000 field sessions is the practical floor for a stable P90; the same jump to P95 needs about 200 lab runs or 10,000 field sessions. Below the floor the estimate wanders between runs because too few observations sit above it, so keep the route at P75 and grow the sample rather than gating on a number your data cannot hold.

Can I use different percentiles for LCP and INP on the same route?

Yes, and you often should. The percentile is a per-metric, per-route parameter, so a checkout page can gate LCP at P90 while a rare-but-severe metric like CLS gates at P90 or P95 and a cheap content metric stays at P75. Store the choice in config so the evaluator stays generic and each metric expresses its own coverage-versus-cost trade.

Does the device and connection profile change which percentile I pick?

It changes the distribution, so it can change the answer. Desktop on cable usually has a tight tail where P75 already covers almost everyone, while mid-range mobile on Fast 3G has a heavy tail that makes the P75-to-P90 gap wide and a stricter percentile more valuable. Always run the spread diagnostic per profile rather than on a blended number.