Head-Based vs Tail-Based RUM Sampling

When you sample real user monitoring beacons, the moment you make the keep-or-drop decision changes which sessions survive into your dataset — and that in turn decides how truthful your percentiles are. This how-to is part of the Custom Performance Beacons & RUM reference, and it sits alongside the broader menu of tactics in RUM Sampling Strategies for High-Traffic Sites. Here we focus narrowly on one axis of that decision: do you sample at the head of a session (decide at page load, before you know what happens) or at the tail (decide at unload, once you know whether the session was fast, slow, or errored)?

The distinction sounds academic until you look at what it does to the numbers your budgets gate on. Head-based sampling is cheap and unbiased for medians, but it starves the far tail of your distribution and makes P99 wobble. Tail-based sampling guarantees you keep the sessions that actually hurt users, but it needs client-side buffering and weight bookkeeping. Most mature pipelines land on a hybrid. This page walks through both mechanics, quantifies the percentile damage, gives you a working client implementation, and shows how to feed the result into a P75/P99 aggregation without double-counting.

How Head-Based Sampling Works

Head-based sampling makes the decision as early as possible — typically in the first few milliseconds after navigation, before any Core Web Vitals metric has even settled. You draw a random number, compare it against a keep rate (say 0.05 for 5% of sessions), and if the session loses the draw you never wire up your PerformanceObserver at all. Nothing is buffered, nothing is discarded later, and the beacon volume is trivially predictable: at a 5% rate on 20 million daily sessions you ship one million beacons regardless of what those sessions do.

Because the die is cast at t=0, head-based sampling is outcome-blind. A session that goes on to record a catastrophic 9-second LCP on a mid-range mobile device over Fast 3G has exactly the same 5% chance of being kept as a session that loads instantly. That neutrality is a feature for central tendency — the sampled median tracks the true median tightly — but it is a liability for the tail, where the interesting sessions are rare by definition.

Where the keep-or-drop decision fires Head-based sampling decides at session start before the outcome is known; tail-based decides at unload once the session outcome is known. When the keep-or-drop decision fires Head-based session runs: navigation to unload decide at t=0 outcome still unknown when the coin is flipped Tail-based session runs: navigation to unload decide at unload slow and errored sessions are known and always kept
Head-based flips the coin before it knows anything; tail-based waits until the session outcome is settled.

How Tail-Based Sampling Works

Tail-based sampling inverts the timing. You instrument every session, buffer the metrics client-side, and defer the keep-or-drop decision to the visibilitychange/pagehide moment when the final values are known. At that point you apply a rule that depends on the outcome: always keep sessions that crossed a badness threshold — a long-task pileup, a JavaScript error, an LCP past your ceiling — and randomly sample the boring fast ones at a low rate.

Concretely, you might keep 100% of sessions whose INP exceeded 200 ms on a mid-range mobile device over Fast 3G, and keep only 2% of everything faster. The slow bucket is small in a healthy population, so the total beacon volume stays modest, yet no expensive session is ever silently thrown away. The cost is complexity: you must instrument every visit (you cannot skip observer setup), you must survive the page unload reliably, and every retained sample now carries a weight so that downstream aggregation can undo the oversampling of the tail.

What Each Does to Percentile Accuracy

The whole reason to care is percentile fidelity. A P75 target on a metric like LCP is estimated from an order statistic: sort the sampled values, walk 75% of the way up. Head-based sampling, being a uniform random draw, produces an unbiased estimator of every quantile — but the variance of the estimate at high quantiles is punishing when the keep rate is low. If genuine 6-second-plus LCP sessions on mid-range mobile over Fast 3G make up 1% of traffic, a 1% uniform head sample expects roughly one such session per 10,000 raw visits to survive as a single kept beacon. With that few samples above the knee, your P99 estimate swings hundreds of milliseconds run to run purely from sampling noise, and it tends to undershoot because the extreme tail is the first thing to vanish.

Tail-based sampling fixes exactly this failure mode by refusing to drop the slow sessions. Because you retain 100% of the tail, your P99 is computed from the real population of slow sessions, not a thin random subsample of it. The catch is that you must weight: a kept fast session represents 50 others (at a 2% rate), while a kept slow session represents only itself. A weighted percentile honours those weights so the median is not dragged upward by the deliberately oversampled tail.

Estimated P99 LCP by strategy Head-based sampling undershoots the true P99 while a hybrid keep-the-tail strategy reproduces it closely. Estimated P99 LCP by strategy mid-range mobile, Fast 3G — true P99 = 6200 ms true 6200 4800 6100 census head-based 1% hybrid keep-tail
A 1% head sample loses most tail sessions and reports a P99 far below reality; keeping the tail restores it.

A Hybrid Strategy: Keep the Tail, Sample the Head

In practice you rarely pick one extreme. The hybrid strategy keeps the tail-based guarantee where it matters and borrows the head-based cheapness everywhere else: retain every slow or errored session at weight 1, and head-sample the fast majority at a low rate with a compensating weight. This gives you a tight, low-variance P99 from the fully retained tail and a cheap, unbiased P75 from the weighted body, at a total volume only slightly above a pure head sample.

The one subtlety worth naming: the badness threshold you use to route a session into the "always keep" bucket must itself be expressed with a percentile and a device-plus-network context, or you will keep the wrong sessions. "Always keep INP over 200 ms on mid-range mobile over Fast 3G" is a rule you can reason about; "always keep slow sessions" is not. Pick thresholds that sit around the P90 of each metric for the device class you care about, so the retained tail is genuinely the top decile of pain rather than half your traffic.

Hybrid sampler data flow Buffered client metrics pass a badness gate; slow sessions are kept at weight one and fast sessions are head-sampled with a compensating weight, then both feed the percentile pipeline. Hybrid sampler data flow Browser buffer metrics past P90 badness? Keep · weight 1 Head-sample 2% weight = 50 yes no P75 / P99 pipeline
Slow sessions bypass the head sample entirely; fast sessions are thinned and up-weighted so aggregates stay unbiased.

Implementing Client-Side Tail Retention

The client half is a buffer plus a decision at page hide. Instrument every session, accumulate the worst values, then at unload apply the hybrid rule and stamp a weight before you send. This example uses PerformanceObserver; if you are also standardising your metric collection, the patterns in Injecting Custom Metrics via PerformanceObserver compose directly with it.

// Hybrid head/tail sampler. Buffers metrics, decides at pagehide.
const session = { lcp: 0, inp: 0, hadError: false };
const HEAD_KEEP_RATE = 0.02;              // 2% of fast sessions
const HEAD_WEIGHT = 1 / HEAD_KEEP_RATE;   // each kept fast session = 50

// P90-ish badness gates for mid-range mobile on Fast 3G.
const LCP_TAIL_MS = 4000;
const INP_TAIL_MS = 200;

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.entryType === 'largest-contentful-paint') {
      session.lcp = entry.startTime;
    }
    if (entry.entryType === 'event' && entry.duration > session.inp) {
      session.inp = entry.duration;
    }
  }
}).observe({ type: 'largest-contentful-paint', buffered: true });

window.addEventListener('error', () => { session.hadError = true; });

function decideAndSend() {
  const isTail =
    session.lcp >= LCP_TAIL_MS ||
    session.inp >= INP_TAIL_MS ||
    session.hadError;

  let weight;
  if (isTail) {
    weight = 1;                 // always keep the tail, no thinning
  } else if (Math.random() < HEAD_KEEP_RATE) {
    weight = HEAD_WEIGHT;       // kept fast session stands in for 50
  } else {
    return;                     // dropped fast session, send nothing
  }

  const body = JSON.stringify({
    lcp: Math.round(session.lcp),
    inp: Math.round(session.inp),
    tail: isTail,
    weight,
  });
  navigator.sendBeacon('/rum', body);
}

addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') decideAndSend();
}, { once: true });

Sending the payload reliably at unload is its own topic — sendBeacon is used above precisely because it survives the page teardown, and the trade-offs of batching those requests are covered in Batching Beacons With sendBeacon.

Feeding P75/P99 Pipelines With Weighted Samples

The server half must respect the weight field, or the whole exercise collapses: a naive unweighted percentile over a tail-oversampled dataset reports a median that is far too slow, because the retained slow sessions are wildly over-represented. A weighted percentile treats each row as if it appeared weight times, so the fast body reasserts its true mass. This is the same aggregation described in Building P75/P99 Aggregation Pipelines; the only change here is the weight column feeding it.

-- Weighted P75 and P99 for LCP over the last hour.
-- Each row counts `weight` times toward the cumulative distribution.
WITH ordered AS (
  SELECT
    lcp,
    weight,
    SUM(weight) OVER (ORDER BY lcp) AS cum_w,
    SUM(weight) OVER ()             AS total_w
  FROM rum_beacons
  WHERE received_at > now() - interval '1 hour'
    AND lcp IS NOT NULL
)
SELECT
  MIN(lcp) FILTER (WHERE cum_w >= 0.75 * total_w) AS lcp_p75,
  MIN(lcp) FILTER (WHERE cum_w >= 0.99 * total_w) AS lcp_p99
FROM ordered;

The table below shows the qualitative trade you are making. The accuracy columns describe how a P99 LCP estimate for mid-range mobile on Fast 3G behaves, and the cost column is beacons per million raw sessions.

Strategy P75 accuracy P99 accuracy Beacons per 1M sessions
Head-based 1% Good Poor (high variance, undershoots) 10000
Head-based 100% Exact Exact 1000000
Tail-based (keep P90+, 2% rest) Good (needs weights) Excellent 28000
Hybrid (keep P90+, 2% rest, weighted) Excellent Excellent 28000

Verifying Your Sampling Doesn't Distort Percentiles

Never trust a sampler you have not back-tested against ground truth. For a bounded window, ship 100% of beacons from a small slice of traffic — say 1% of sessions kept unconditionally as a census channel — and compute percentiles from that unbiased census. Then run your hybrid sampler over the same window and compare. If the weighted hybrid P75 sits within a few percent of the census P75, and the hybrid P99 tracks the census P99 rather than sagging below it, your weights are correct. If the median is skewed high, your weight math is wrong or your badness threshold is catching too much of the body. This mirrors the field-versus-field discipline discussed in Synthetic Monitoring vs RUM Trade-offs: you validate the cheap signal against a trusted, expensive one before you gate on it.

Frequently Asked Questions

Does head-based sampling bias my median?

No. A uniform head-based sample is an unbiased estimator of every percentile, including the median. The problem is variance, not bias: at low keep rates the far tail is so thinly sampled that P99 swings run to run and tends to undershoot. Central tendency stays reliable; the extremes do not.

Why do tail-kept sessions need a weight of 1 while sampled sessions need a larger weight?

Tail sessions are kept at 100%, so each one represents exactly itself — weight 1. Fast sessions are thinned to a keep rate such as 2%, so each survivor stands in for the roughly 50 that were dropped — weight 50. Feeding those weights into a weighted percentile restores the true population mass.

What threshold should route a session into the always-keep bucket?

Pick a value near the P90 of each metric for the device class you care about, for example INP over 200 ms or LCP over 4000 ms on mid-range mobile over Fast 3G. That keeps the genuinely slow top decile without dragging half your traffic into the tail bucket and inflating volume.

Can I use head-based sampling if I only report medians and P75?

Often yes. If your budgets gate on P75 and you never look above P90, a plain head-based sample at a rate that yields thousands of samples per window is accurate and far simpler. Tail retention earns its complexity specifically when you gate on P95, P99, or error-correlated regressions.