Interpolating Percentiles From Sparse Samples

A performance gate lives or dies by the percentile it computes. When a pull-request job runs a metric five, eight, or twelve times, the number you feed into the gate is only as trustworthy as the method that turned those raw samples into a P75 or P90. This page is part of the Percentile-Based Threshold Tuning reference, and it covers one specific failure mode: naive nearest-rank percentiles snapping to whole order statistics when the sample count is small, producing thresholds that jolt up and down between otherwise-identical runs.

The fix is linear interpolation between order statistics — the same method NumPy, R (type 7), and most spreadsheet PERCENTILE functions use by default. It smooths the estimate, makes small-sample percentiles reproducible, and gives you a principled minimum-sample rule. Below you will find the two methods contrasted, why sparse samples swing, the interpolation formula worked by hand, minimum-n guidance, a copy-paste JavaScript implementation, and a way to verify the result. Every threshold quoted is for interaction latency on mid-range mobile over emulated Fast 3G, the profile most CI runners emulate.

Nearest-Rank vs Linear Interpolation

The nearest-rank percentile picks a single existing sample. For percentile p and n samples, it takes the value at rank ceil(p * n) in the sorted list. Because that rank is a whole number, the result is always one of your measured values — it can never land between two of them. With a large field dataset that is fine, but with a handful of CI runs it means the estimate can only ever equal x[0], x[1], and so on, and it hops discretely as the sample count changes.

Linear interpolation instead computes a fractional rank and blends the two neighbouring order statistics. Using the common type-7 definition, the fractional rank is h = (n - 1) * p. Let k = floor(h) and f = h - k. The percentile is x[k] + f * (x[k+1] - x[k]). When f is zero the result equals an exact sample; otherwise it sits proportionally between two of them. That single change removes the staircase behaviour and lets the estimate move continuously as the underlying data drifts.

Consider twelve sorted INP samples (ms) from one PR job on mid-range mobile over Fast 3G: 180, 195, 210, 220, 235, 250, 265, 280, 300, 330, 380, 450. Nearest-rank P75 takes rank ceil(0.75 * 12) = 9, the ninth value, which is 300 ms. Linear interpolation takes h = 11 * 0.75 = 8.25, so k = 8, f = 0.25, giving x[8] + 0.25 * (x[9] - x[8]) = 300 + 0.25 * 30 = 307.5 ms. The two agree closely here, but the interpolated value carries information from both neighbours, so it will not lurch the moment one sample shifts.

Nearest-rank versus interpolated P75 Twelve sorted interaction-latency samples plotted along an axis, with the nearest-rank P75 marker on one sample and the interpolated P75 marker sitting a quarter of the way to the next. Twelve sorted INP samples (ms) - mid-range mobile, Fast 3G 180 195 210 220 235 250 265 280 300 330 380 450 Nearest-rank P75 = 300 Interpolated P75 = 307.5 25% of the way to x[9]
Nearest-rank locks onto a single measured sample (300 ms); linear interpolation lands a quarter of the way toward the next order statistic (307.5 ms).

Why Small Samples Swing

The instability of nearest-rank is not about the values themselves; it is about which order statistic the rank formula selects. That selected index changes discretely as n changes, so adding or dropping a single run can jump the percentile to an entirely different measured value. With eight samples, nearest-rank P75 takes rank ceil(0.75 * 8) = 6, the sixth value. With nine samples it takes rank ceil(0.75 * 9) = 7, the seventh value. If your ninth run happened to be a slow outlier, the whole P75 shifts to a larger sample even though the underlying page did not get slower.

Interpolation is far gentler because the fractional rank moves smoothly. For P75 at n = 8 the fractional rank is 7 * 0.75 = 5.25; at n = 9 it is 8 * 0.75 = 6.0; at n = 10 it is 9 * 0.75 = 6.75. The blend weight slides gradually rather than teleporting between indices, so a single extra run nudges the estimate instead of snapping it. This is exactly the reproducibility you want from a gate that reruns on every commit, and it pairs well with the variance-reduction tactics in Reducing Lighthouse CI Variance in Staging.

The deeper point is that a percentile from a sparse sample is an estimate of a population quantile, and the estimate has variance. Interpolation lowers that variance a little by using two data points instead of one, but it cannot manufacture information you did not collect. That is why the method has to be paired with a minimum-sample rule and, when the count is too low, with widening the window rather than trusting a number computed from three runs.

Interpolating between two order statistics A sloped line connecting the eighth and ninth order statistics, with the interpolated P75 marked a quarter of the way along and the formula annotated beneath. Blending the two neighbouring samples x[8] = 300 ms x[9] = 330 ms P75 = 307.5 ms f = 0.25 h = (n - 1) x p = 11 x 0.75 = 8.25 k = floor(h) = 8, f = 0.25 x[8] + f(x[9] - x[8]) = 307.5
The interpolated percentile is the lower order statistic plus the fractional weight times the gap to the next one.

The Interpolation Formula, Worked

Here is the full procedure for the same twelve-sample dataset, computing both P75 and P90 for interaction latency on mid-range mobile over Fast 3G. Work in sorted order, index from zero, and never round until the very end.

  1. Sort ascending: 180, 195, 210, 220, 235, 250, 265, 280, 300, 330, 380, 450. There are n = 12 values.
  2. For P75, compute the fractional rank h = (12 - 1) * 0.75 = 8.25. Split it: k = 8 and f = 0.25.
  3. Read the two neighbours: x[8] = 300 and x[9] = 330. Blend: 300 + 0.25 * (330 - 300) = 307.5 ms.
  4. For P90, compute h = 11 * 0.90 = 9.9, so k = 9 and f = 0.9.
  5. Read x[9] = 330 and x[10] = 380. Blend: 330 + 0.9 * (380 - 330) = 375 ms.

Compare that against the nearest-rank answers: P90 nearest-rank takes rank ceil(0.90 * 12) = 11, the eleventh value, which is 380 ms — five milliseconds above the interpolated 375 ms and, more importantly, pinned to a single sample. The table below lays out both methods side by side so you can see how the interpolated column tracks the data more smoothly. If you are deciding which percentile to gate on in the first place, pair this with Choosing Between P75 and P90 Budget Targets, which weighs how aggressively each target polices the tail.

Percentile Fractional rank h k, f Neighbours x[k], x[k+1] Interpolated (ms) Nearest-rank (ms)
P50 5.5 5, 0.5 250, 265 257.5 265
P75 8.25 8, 0.25 300, 330 307.5 300
P90 9.9 9, 0.9 330, 380 375.0 380
P95 10.45 10, 0.45 380, 450 411.5 450

Minimum Sample Sizes

Interpolation stabilises the estimate but does not license computing a P90 from four runs. The higher the percentile, the more of the tail you are estimating, and the tail is where sparse samples are thinnest. A rough working rule for CI, matching the practice in Building P75/P99 Aggregation Pipelines, is that you want enough samples that at least a few of them fall above your target percentile. For P75 that means roughly twenty or more runs; for P90, forty or more; for anything at P95 and beyond, treat a hundred as the floor and prefer field data over synthetic runs entirely.

Target percentile Minimum samples (guideline) Samples expected above it Recommended source
P50 10 5 Per-PR CI runs
P75 20 5 Per-PR CI runs, batched
P90 40 4 Nightly synthetic + RUM
P95 100 5 Field RUM only

When your per-PR job cannot reach these counts, you have two choices: interpolate anyway and accept a wide confidence interval, or widen the window so more samples flow in. The decision tree below makes the call explicit. Quantifying the uncertainty of a small-sample percentile is itself a topic worth its own treatment in Bootstrap Confidence Intervals for LCP.

Interpolate or widen the window A decision tree that routes on whether the sample count meets the minimum for the target percentile, choosing interpolation when it does and widening the window when it does not. Compute P75 / P90 from this CI sample n meets minimum for the target? yes Linear interpolation no Widen the window more runs or a longer time range
Interpolate only when the sample count clears the minimum for the target percentile; otherwise gather more data before trusting the number.

When to Widen the Window Instead

Widening the window means pooling more measurements before you compute the percentile, and it is almost always the right move when a per-PR job produces only a handful of runs. There are three practical ways to do it. First, raise the repeat count on the job itself so a single commit produces fifteen or twenty measurements rather than five. Second, pool a short rolling time range — for example the last twenty-four hours of scheduled synthetic runs for the same route — so the sample crosses several commits that did not change that page. Third, blend in field data, which arrives at a volume synthetic runs can never match; the sampling side of that is covered in RUM Sampling Strategies for High-Traffic Sites.

The trade-off is freshness. A wider time window mixes in older commits, so a real regression introduced in the current PR gets diluted by faster historical runs and may slip under the gate. Keep the window just wide enough to reach the minimum sample count for your target percentile, and no wider. For a P75 interaction-latency gate on mid-range mobile over Fast 3G, twenty pooled runs from the last few hours is usually a good balance; for a P90 gate on the same profile you will want closer to forty, which typically forces you toward nightly runs or field data rather than a single PR job.

A JavaScript Implementation

The following module computes a linear-interpolation percentile and guards it with a minimum-sample check, returning a structured result your gate can branch on. It has no dependencies and runs in Node or the browser. The MIN_SAMPLES map encodes the guidance from the table above; adjust it to your own tolerance.

// percentile.js - type-7 linear interpolation with a minimum-sample guard.
const MIN_SAMPLES = { 50: 10, 75: 20, 90: 40, 95: 100 };

function percentileLinear(samples, p) {
  if (!Array.isArray(samples) || samples.length === 0) return NaN;
  const sorted = [...samples].sort((a, b) => a - b);
  const n = sorted.length;
  if (n === 1) return sorted[0];
  const fraction = p / 100;
  const rank = (n - 1) * fraction;
  const k = Math.floor(rank);
  const f = rank - k;
  if (k + 1 >= n) return sorted[n - 1];
  return sorted[k] + f * (sorted[k + 1] - sorted[k]);
}

function safePercentile(samples, p) {
  const need = MIN_SAMPLES[p] ?? 20;
  if (samples.length < need) {
    return {
      ok: false,
      value: percentileLinear(samples, p),
      reason: `only ${samples.length} samples; need >= ${need} for P${p} - widen the window`,
    };
  }
  return { ok: true, value: percentileLinear(samples, p), reason: null };
}

module.exports = { percentileLinear, safePercentile };

Wiring it into a gate looks like this. The job collects every run's interaction-latency measurement, computes the guarded percentile, and fails the build only when the sample count is sufficient and the value exceeds the budget. This keeps a thin PR sample from either passing a real regression or blocking on a number computed from too little data.

const { safePercentile } = require("./percentile");

// inpSamples: interaction latency (ms) from every run in this job.
const inpSamples = [180, 195, 210, 220, 235, 250, 265, 280, 300, 330, 380, 450];
const BUDGET_P75_MS = 320; // mid-range mobile, Fast 3G

const result = safePercentile(inpSamples, 75);
if (!result.ok) {
  console.warn(`Percentile not gated: ${result.reason}`);
  process.exit(0); // do not fail on an underpowered sample
}
if (result.value > BUDGET_P75_MS) {
  console.error(`P75 INP ${result.value.toFixed(1)} ms exceeds ${BUDGET_P75_MS} ms`);
  process.exit(1);
}
console.log(`P75 INP ${result.value.toFixed(1)} ms within budget`);

Verifying Your Percentile

Never ship a percentile function without pinning it to a known-good reference. The quickest check is to compute the same P75 in a tool with a documented method and confirm they match to the decimal. NumPy's numpy.percentile(data, 75) and Google Sheets' PERCENTILE.INC both use the type-7 definition this page describes, so a value of 307.5 ms from the twelve-sample dataset should appear identically in all three. If your CI number and the reference diverge, the usual causes are indexing from one instead of zero, using ceil where the formula needs floor, or silently dropping a sample during parsing.

A second, cheaper guard is a unit test on boundary cases: a single sample must return itself, an exact percentile (f = 0) must return an exact order statistic, and P100 must return the maximum. Add one assertion that the interpolated P75 of the reference dataset equals 307.5 and wire it into the same suite that runs your gate. Because this percentile feeds a gate that can block merges, treat it with the same rigour as the significance testing in Statistical Significance Testing for Noisy CI, and read Using 75th Percentile for Real-World INP Targets for why P75 is the anchor most teams calibrate against.

Frequently Asked Questions

Why does nearest-rank P75 change when I add one more CI run?

Nearest-rank selects the value at rank ceil(p times n), which is a whole number that shifts to a different order statistic as n changes. Going from eight to nine samples moves P75 from the sixth to the seventh value, so a single extra run can snap the estimate to an entirely different measured number. Linear interpolation uses a fractional rank that slides smoothly, so the same extra run nudges the value instead of jumping it.

How many samples do I need before a P90 gate is trustworthy?

As a working rule, aim for at least forty samples for P90 so that roughly four of them fall above the target and the tail is actually represented. Below that, interpolation still returns a number but its confidence interval is wide. For a P90 interaction-latency gate on mid-range mobile over Fast 3G, that usually means pooling nightly synthetic runs or field data rather than relying on a single pull-request job.

Which interpolation method should I standardise on?

Use the type-7 linear method, h = (n - 1) times p, because it is the default in NumPy, R, and spreadsheet PERCENTILE.INC, which makes cross-checking trivial. The exact quantile definition matters less than picking one and using it everywhere, so every dashboard, gate, and baseline computes the identical number from the same samples.

Should I interpolate or just widen the sample window?

Do both in order: always interpolate rather than using nearest-rank, and widen the window whenever the sample count is below the minimum for your target percentile. Widening pools more runs or a short rolling time range so the estimate has enough data, but keep the window only as wide as needed so a genuine regression in the current change is not diluted by faster historical runs.

Does interpolation hide real regressions?

No. Interpolation only changes how a fixed set of samples is summarised into a percentile; it does not smooth across commits or remove outliers. A real regression that shifts the whole distribution upward moves the interpolated percentile up just as it would move the nearest-rank value. What interpolation removes is spurious movement caused by the rank formula snapping between order statistics.