RUM Sampling Strategies for High-Traffic Sites

At a million sessions a day, beaconing every page view is wasteful and expensive — but sampling carelessly biases your percentiles and starves the tail you most need to watch. The goal is to send the fewest beacons that still let you assert a P75 and P99 you can gate a deploy on. This guide is part of the Custom Performance Beacons & RUM reference and covers head-based versus tail-based sampling, the sample-rate-versus-confidence tradeoff, deterministic sticky sampling, cost control, and the failure modes that silently green a gate.

Every threshold in this guide is anchored to a device class and connection, because a sample that is generous for desktop on cable can be starved for mid-range mobile on Fast 3G — the segment where your worst Core Web Vitals actually live. Treat a "segment" throughout as a device-class and route pair (for example mid-range mobile on the checkout route), never the whole site rolled together.

When to Decide: Head-Based vs Tail-Based

The core decision is when you decide to keep a session. Head-based sampling decides at page load, before any metric exists — you flip a weighted coin the moment the document parses and either wire up the beacon or you do not. It is cheap, adds no runtime cost to dropped sessions, and produces an unbiased distribution, so a P75 LCP of 2500 ms on mid-range mobile over Fast 3G computed from a head-based sample is the same number you would have measured at 100% capture. Tail-based sampling decides after the metrics are known: the session buffers its LCP, INP, and CLS, and at unload a rule decides whether the data is interesting enough to send. That lets you over-sample slow sessions and enrich the P99, but it costs memory on every session and biases the median upward if you let it drive the primary distribution.

The durable pattern is head-based as the foundation with a narrow tail-based override layered on top. Decide the head rate for unbiased percentiles, then add an always-keep rule for outliers so a P99 INP of 500 ms on mid-range mobile is never coin-flipped away. The full trade study lives in Head-Based vs Tail-Based RUM Sampling; the short version is below.

Head-based versus tail-based decision points A session timeline with a head-based decision at load and a tail-based decision after LCP and INP are known. Two moments you can decide to keep a session Head-based: decide at load before any metric exists Tail-based: decide at unload once LCP and INP are known Page load LCP paint INP settled Beacon sent unbiased, cheap targets the slow tail
Head-based sampling commits at page load for an unbiased P75; a tail-based override at unload rescues slow outliers for the P99.

Sample Rate vs Confidence

How low you can drop the rate depends on traffic and which percentile you gate on. The deeper the percentile, the more raw sessions you need so enough land in the tail. Only about 1% of kept sessions populate the P99 bucket, so a rate that gives a rock-solid P75 can still leave the P99 as noise. The table shows the approximate kept-session volume needed for a stable daily reading at the whole-site level.

Daily sessions Sample rate Kept/day Stable P75? Stable P99?
10,000 100% 10,000 Yes Marginal
100,000 25% 25,000 Yes Yes
1,000,000 5% 50,000 Yes Yes
10,000,000 1% 100,000 Yes Yes

A useful rule: aim for at least ~1,000 kept sessions per segment per window for a trustworthy P75, and ~10,000 for a P99 you can gate on. The chart below makes the shape of that requirement concrete — the floor climbs by roughly a decade as you move from the median to the P99. What matters is the absolute count that lands in each bucket, not the headline percentage: 5% of a million sessions is 50,000 kept, far more than enough, while 5% of a thousand-session route is 50 kept, which cannot support even a P75 on mid-range mobile over Fast 3G. For the confidence math behind acting on these numbers, see Statistical Significance Testing for Noisy CI.

Kept sessions required by gated percentile Bars showing the minimum kept sessions per segment needed to gate the P50, P75, P90, and P99 on a log scale. Minimum kept sessions per segment to gate a percentile P50 P75 P90 P99 100 1,000 3,000 10,000 100 1,000 10,000 kept sessions per segment, log scale
The kept-session floor rises about a decade per step from median to tail, so a P99 gate needs roughly ten times the volume of a P75 gate.

Diagnostic Steps

Before lowering the rate, confirm you have enough tail volume to keep gating on the P99 for every segment, not just the aggregate.

  1. Count kept sessions per segment over your gate window to check none are starved:

    curl -fsSL "$RUM_QUERY_URL?window=28d&group=device,route&select=count" | \
      jq '.[] | select(.count < 1000)'
    # [] means every segment clears the floor; any rows printed are under-sampled
  2. Check how many sessions actually populate the P99 bucket — too few and the percentile is noise:

    curl -fsSL "$RUM_QUERY_URL?window=28d&metric=LCP&select=count" | jq '.count * 0.01'
    # 503  -> ~503 sessions define the P99; comfortable above the ~100 floor
  3. Confirm the head decision is not correlated with anything that shifts the distribution, such as page type or geography, by comparing the sampled P75 against a brief 100% capture window:

    curl -fsSL "$RUM_QUERY_URL?window=1d&sampled=true&metric=LCP&select=p75" | jq
    curl -fsSL "$RUM_QUERY_URL?window=1d&sampled=false&metric=LCP&select=p75" | jq
    # the two P75 values should agree within a few percent for mid-range mobile

Implementation: Deterministic Sticky Sampling

Sampling must be deterministic and sticky: every page view in a session makes the same keep/drop decision, so a session is never half-recorded and a multi-page journey is not silently truncated. Hash the session id to a stable number in [0,1) and compare against the rate — no randomness per page, no server round-trip, no cookie that a privacy setting can strip. Because the hash is a pure function of the session id, a reload, a soft navigation, and a back-forward restore all land on the same side of the threshold.

// sampling.js — runs before the beacon library loads
const SAMPLE_RATE = Number(window.__RUM_SAMPLE_RATE ?? 0.05); // 5%

// Stable per-session decision: same session id -> same hash -> same outcome.
async function shouldSample(sessionId) {
  const data = new TextEncoder().encode(sessionId);
  const digest = await crypto.subtle.digest("SHA-256", data);
  // Use the first 4 bytes as an unsigned int, normalize to [0,1).
  const n = new DataView(digest).getUint32(0) / 0xffffffff;
  return n < SAMPLE_RATE;
}

// Persist the session id so the decision is sticky across page views.
let sid = sessionStorage.getItem("rum_sid");
if (!sid) { sid = crypto.randomUUID(); sessionStorage.setItem("rum_sid", sid); }

shouldSample(sid).then((keep) => {
  if (keep) import("./rum-beacon.js"); // only sampled sessions load the lib
});

For tail-based capture, layer a small always-on rule on top: beacon any session whose LCP or INP crosses the "poor" threshold — 4000 ms LCP or 500 ms INP at P75 on mid-range mobile over Fast 3G — regardless of the head decision, so slow outliers are never sampled away. Cap that override, for example the first 5,000 poor sessions per window, to keep cost bounded. Keep the override payload lean per Designing Efficient RUM Beacon Payloads, and flush both head and tail beacons through one queue as described in Batching Beacons With sendBeacon so a sampled session still costs a single request at unload.

Deterministic sticky sampling with a tail override A session id is hashed to a number in zero to one, compared to the sample rate to keep or drop, while a tail override forces a keep for poor sessions. One decision per session, sticky across page views Sticky session id from sessionStorage SHA-256 to uint32 normalize to n in [0,1) n < rate? e.g. 0.05 No Drop Yes Keep: load beacon lib LCP or INP is poor? checked at unload, any session override to always keep
The head path keeps a fixed fraction deterministically; the tail override forces a keep for poor sessions so the P99 is never sampled away.

Cost Control and Edge Cases

Sampling exists to bound cost, so tie the rate to a budget rather than picking a round number. If ingestion and storage cost roughly a fixed amount per kept session, then a 5% rate on a million daily sessions bills for 50,000 beacons, and doubling the rate doubles the bill for a P75 that was already stable — spend that headroom on the tail override instead. Set the head rate as low as the largest segment's P99 floor allows, then let the override carry the extra slow-session detail at a capped, predictable cost.

Three edge cases catch teams repeatedly. First, low-traffic segments: a route with 2,000 daily sessions on mid-range mobile yields only 100 kept at 5%, enough for a rough P75 but not a P99 you should block a deploy on — gate it on P75 only, or raise its rate to 25% while leaving the busy routes at 5%. Second, bot and synthetic traffic: exclude it before sampling, or a crawler fleet with fast wired connections drags your sampled P75 LCP below the human number and hides a real regression. Third, sessionStorage loss: private-mode or storage-partitioned browsers can drop the sticky id, re-rolling the decision on each page and biasing multi-page journeys toward whichever pages happened to keep — fall back to an in-memory id for the tab and treat storage failure as a keep so you never silently under-count. If your custom metrics ride the same beacon, register them the way Injecting Custom Metrics via PerformanceObserver describes so they respect the same sampling decision.

CI Gating Assertion

Sampling that quietly collapses — a deploy that breaks sessionStorage, a rate set to zero by mistake, a bot filter that eats real traffic — leaves the gate reading from too few sessions and silently passing. This job asserts that kept volume is sufficient before any percentile gate runs, so an under-sampled window fails loudly instead of giving a false green.

# .github/workflows/sample-volume-gate.yml
name: RUM Sample Volume Gate
on:
  schedule:
    - cron: "0 6 * * *"
  workflow_dispatch:
jobs:
  sample-volume:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - name: Assert sample volume sufficiency
        env:
          RUM_QUERY_URL: ${{ secrets.RUM_QUERY_URL }}
        run: |
          curl -fsSL "$RUM_QUERY_URL?window=28d&group=device,route&select=count" -o vol.json
          node -e '
            const rows = require("./vol.json");
            const P75_FLOOR = 1000, P99_FLOOR = 10000;
            let failed = false;
            for (const r of rows) {
              const okP75 = r.count >= P75_FLOOR;
              const okP99 = r.count >= P99_FLOOR;
              console.log(`[SampleGate] ${r.device}/${r.route} kept=${r.count} P75:${okP75?"OK":"LOW"} P99:${okP99?"OK":"LOW"}`);
              if (!okP75) failed = true;
            }
            process.exit(failed ? 1 : 0);
          '

Verification

The passing signal is every segment clearing the P75 floor, with P99-eligible segments also above their floor:

[SampleGate] mobile/checkout kept=4120 P75:OK P99:LOW
[SampleGate] desktop/home kept=18800 P75:OK P99:OK

A P99:LOW segment means you may gate that route on P75 but should not block on its P99 yet — either raise the sample rate for that route or widen the window from 28 days to 56. If a segment shows P75:LOW, the build fails; confirm the sticky session id is persisting and the rate is not accidentally zero. Once volume is trustworthy, hand the kept sessions to Building P75/P99 Aggregation Pipelines to turn them into the gated percentiles your budget policy asserts.

Frequently Asked Questions

Head-based or tail-based sampling for budget gating?

Use head-based sampling as the foundation: it is cheap, decides at page load before metrics exist, and gives an unbiased distribution you can compute P75 from directly. Add a narrow tail-based override that always keeps "poor" outliers so your P99 is not sampled away. Pure tail-based sampling biases the median and is harder to operate, so reserve it for capturing slow-session detail, not for the primary gate.

Won't a 5% sample make my percentiles unreliable?

Not at high traffic. What matters is the absolute count of kept sessions per segment, not the percentage — 5% of a million sessions is 50,000, far more than enough for a stable P75 LCP on mid-range mobile over Fast 3G and a usable P99. The risk is low-traffic segments, where 5% may starve the tail; gate those on P75 only, or raise their rate. The statistical reasoning is detailed in Statistical Significance Testing for Noisy CI.

Why hash the session id instead of calling Math.random each page?

Hashing makes the decision deterministic and sticky: the same session id always produces the same number in [0,1), so every page view in a multi-page journey keeps or drops together and a session is never half-recorded. A per-page random draw would keep some pages and drop others in the same session, truncating funnels and biasing which page types survive. A pure hash also needs no cookie or server round-trip.

How do I keep the P99 without over-sampling everything?

Layer a capped tail override on top of a low head rate. Keep the head rate as low as your largest segment's ~10,000-kept P99 floor allows, then always beacon any session whose LCP exceeds 4000 ms or INP exceeds 500 ms at P75 on mid-range mobile over Fast 3G, capped at a few thousand per window. That preserves the slow tail at a bounded cost without doubling the whole sample.

Should a low-traffic route use the same rate as the homepage?

No. A route with 2,000 daily sessions yields only about 100 kept at 5%, which supports a rough P75 but not a gatable P99. Raise that route's rate to 25% or 100% while leaving high-volume routes at 5%, and gate the thin route on P75 until its kept count clears the ~10,000 floor. The volume gate in this guide flags any starved segment before the percentile gate runs.