Rate Limiting and Validating RUM Beacons

A RUM collector is an unauthenticated public endpoint that writes to your warehouse. Without validation it will eventually receive a beacon with a route field containing a full URL with a session token, a metric value of 1e308, or ten thousand requests a second from a single misbehaving client. This guide sits under Self-Hosted Beacon Collection and covers a validation layer that runs in under a millisecond, cardinality controls that keep the warehouse queryable, and rate limits that shed abuse without discarding legitimate bursts.

Prerequisites

How to build the guard

Step 1 — Reject on shape before you look at values

The cheapest rejections come first: wrong method, wrong content type, oversized body, wrong origin.

const MAX_BODY = 64 * 1024;                  // matches the sendBeacon limit
const ALLOWED_ORIGINS = new Set(['https://www.example.com', 'https://example.com']);

export function preflight(request) {
  if (request.method !== 'POST') return 'method';
  const origin = request.headers.get('origin');
  if (origin && !ALLOWED_ORIGINS.has(origin)) return 'origin';
  const type = request.headers.get('content-type') || '';
  if (!type.includes('application/json') && !type.includes('text/plain')) return 'content-type';
  const len = Number(request.headers.get('content-length') || 0);
  if (len > MAX_BODY) return 'too-large';
  return null;
}

An origin check is not security — anything can forge the header — but it removes the overwhelming majority of accidental traffic, and it does so before you have parsed anything.

Step 2 — Validate each beacon against a schema

// validate.js — allowlist everything. Anything not described is dropped.
const METRICS = new Set(['LCP', 'INP', 'CLS', 'FCP', 'TTFB', 'route_change']);
const RATINGS = new Set(['good', 'needs-improvement', 'poor']);

// Sanity bounds per metric: values outside these are corrupt, not slow.
const BOUNDS = {
  LCP: [0, 120_000], INP: [0, 60_000], CLS: [0, 100],
  FCP: [0, 120_000], TTFB: [0, 120_000], route_change: [0, 300_000],
};

const str = (v, max) => (typeof v === 'string' && v.length && v.length <= max ? v : null);

export function validateBeacon(b) {
  if (!b || typeof b !== 'object') return { ok: false, reason: 'shape' };
  const metric = str(b.metric, 32);
  if (!metric || !METRICS.has(metric)) return { ok: false, reason: 'metric' };

  const value = Number(b.value);
  const [lo, hi] = BOUNDS[metric];
  if (!Number.isFinite(value) || value < lo || value > hi) {
    return { ok: false, reason: 'value-range' };
  }

  const id = str(b.id, 64);
  if (!id) return { ok: false, reason: 'id' };

  return {
    ok: true,
    row: {
      id,
      metric,
      value: Math.round(value * 1000) / 1000,
      rating: RATINGS.has(b.rating) ? b.rating : null,
      route: normaliseRoute(b.route),
      device_tier: str(b.deviceTier, 16),
      network: str(b.network, 16),
      release: str(b.release, 32),
    },
  };
}

Two design choices matter here. The bounds reject impossible values rather than clamping them — a clamped 120-second LCP is indistinguishable from a real one and quietly poisons the tail. And the output is an explicit row built field by field, so an attacker-supplied property can never reach the writer.

Step 3 — Normalise routes to control cardinality

The single most damaging field is route. Left unchecked it arrives as a full URL with ids, query strings and occasionally credentials, producing millions of distinct values that make percentiles impossible and create a privacy problem.

// Route templating at the edge: no ids, no query strings, no fragments.
const SEGMENT_RULES = [
  [/^\d+$/, ':id'],
  [/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, ':uuid'],
  [/^[0-9a-f]{24,}$/i, ':hash'],
  [/@/, ':email'],
];

export function normaliseRoute(raw) {
  if (typeof raw !== 'string' || !raw) return '/unknown';
  let path = raw.split('?')[0].split('#')[0].slice(0, 200);
  const parts = path.split('/').filter(Boolean).slice(0, 8).map((seg) => {
    for (const [re, token] of SEGMENT_RULES) if (re.test(seg)) return token;
    return seg.length > 40 ? ':long' : seg;
  });
  return '/' + parts.join('/');
}

Templating at the edge rather than at query time is deliberate: once a raw URL is written to storage it is in your backups, your replicas and your retention policy, which is the argument made in Anonymizing IP and Geolocation in RUM.

Step 4 — Cap cardinality with an allowlist where you can

For fields with a known domain — device tier, network, release, rating — reject anything not in the set rather than storing it. For route, where the domain grows with the application, a periodic check that catches new values is the practical equivalent.

-- Weekly cardinality guard: new route values and their volume.
SELECT route, count() AS n, min(ts) AS first_seen
FROM rum_events
WHERE ts >= now() - INTERVAL 7 DAY
GROUP BY route
HAVING first_seen >= now() - INTERVAL 7 DAY
ORDER BY n DESC
LIMIT 50;

A new route with high volume is a launch. A hundred new routes with one event each is a normalisation bug, and it is much cheaper to catch in week one than after a quarter of storage.

Step 5 — Rate limit per session, then per address

Legitimate clients send bursts: a session that batches ten beacons and flushes once is normal. What is not normal is thousands of requests from one source.

// Two-tier limit. The session limit catches a runaway loop in your own
// code; the address limit catches abuse. Both fail open on storage errors.
const SESSION_LIMIT = 120;      // beacons per session per hour
const ADDRESS_LIMIT = 2000;     // beacons per address per minute

export async function rateLimit(env, sessionId, addressHash) {
  try {
    const [s, a] = await Promise.all([
      env.LIMITS.get(`s:${sessionId}`),
      env.LIMITS.get(`a:${addressHash}`),
    ]);
    if (Number(s || 0) > SESSION_LIMIT) return 'session';
    if (Number(a || 0) > ADDRESS_LIMIT) return 'address';
    // Increment with short TTLs; approximate counting is fine here.
    await Promise.all([
      env.LIMITS.put(`s:${sessionId}`, String(Number(s || 0) + 1), { expirationTtl: 3600 }),
      env.LIMITS.put(`a:${addressHash}`, String(Number(a || 0) + 1), { expirationTtl: 60 }),
    ]);
    return null;
  } catch {
    return null;                 // never lose data because the limiter is down
  }
}

Failing open is the right default for a telemetry endpoint. A limiter outage that silently discards a day of beacons is worse than the abuse it was protecting against.

Step 6 — Always answer 204, and record why you dropped

// The client cannot act on an error, so never make it retry a bad beacon.
export default {
  async fetch(request, env, ctx) {
    const bad = preflight(request);
    if (bad) return new Response(null, { status: 204 });

    const payload = await request.json().catch(() => null);
    const beacons = Array.isArray(payload?.beacons) ? payload.beacons.slice(0, 50) : [];

    const rows = [], rejects = [];
    for (const b of beacons) {
      const v = validateBeacon(b);
      (v.ok ? rows : rejects).push(v.ok ? v.row : v.reason);
    }

    // Rejections are a metric. A validator with no counter is a black hole.
    ctx.waitUntil(Promise.all([
      rows.length ? writeRows(env, rows) : null,
      rejects.length ? countRejections(env, rejects) : null,
    ]));

    return new Response(null, { status: 204 });
  },
};
What the validator rejects in a normal weekAlmost all rejections are your own client sending something the schema does not describe. Abuse is the smallest category and the one everyone builds for first.What the validator rejects in a normal weekRejections per million beacons, by reasonUnknown metric name4,100Route over cardinality rules2,600Value out of bounds940Missing beacon id310Rate limited18001,2502,5003,7505,000
Almost all rejections are your own client sending something the schema does not describe. Abuse is the smallest category and the one everyone builds for first.
Route cardinality with and without normalisationRaw paths grow without bound and make per-route percentiles impossible within a fortnight. Templating caps the dimension at the number of screens the application actually has.Route cardinality with and without normalisationDistinct route values in the events table, cumulative050,000100,000150,000200,000w1w2w3w4w5w6w7w8Raw pathsTemplated at the edge
Raw paths grow without bound and make per-route percentiles impossible within a fortnight. Templating caps the dimension at the number of screens the application actually has.

Watching the rejections

The rejection counter is the most useful output of this whole layer, because it is a leading indicator of client bugs.

A spike in unknown metric names means a release started sending something the collector does not know about — usually a new custom metric that skipped the schema conversation. Data is being lost right now.

A spike in out-of-bounds values means a measurement bug: a timer started before navigation, a value in seconds where the schema expects milliseconds, or a clock jump.

A slow rise in route rejections means the normalisation rules are behind the application. New id formats appear as products grow.

Rate limiting anyone at all is worth investigating once. It is usually a retry loop in your own client rather than an attacker.

Alerting on the rejection rate crossing a fraction of a percent catches all four within an hour, which is far faster than noticing a gap in a dashboard a week later.

Verifying it works

  1. Rejection rate under about 0.5% of beacons in steady state, with every rejection reason counted separately.
  2. Route cardinality bounded — a few hundred values, not thousands. Check weekly with the query above.
  3. No raw URLs, query strings or identifiers in stored rows. Sample a hundred rows and read them; this is worth doing by eye periodically.
  4. The endpoint still answers 204 under load, including when the rate limiter storage is unavailable.
  5. Validation cost stays under a millisecond at p99 — measure it, because a slow validator becomes beacon loss on flaky connections.
The order the checks run inEvery stage that can reject runs before anything expensive happens, and the response is returned before the write — so a slow warehouse never becomes beacon loss.The order the checks run inCheapest rejection first, storage lastPreflightmethod, origin, sizeSchemaallowlist and boundsNormaliseroute templatingRate limitsession then addressWriteafter the 204 is sent
Every stage that can reject runs before anything expensive happens, and the response is returned before the write — so a slow warehouse never becomes beacon loss.

Edge cases and gotchas

  • Clamping instead of rejecting. A clamped value is indistinguishable from a real one and corrupts the tail permanently. Reject and count.
  • Failing closed on limiter errors. A telemetry endpoint that discards data when its own storage is unhealthy loses precisely the window you will want to investigate.
  • Origin allowlists and preview deployments. Every preview URL is a distinct origin; use a pattern or accept that preview traffic is dropped.
  • Batch size limits. A client that queues aggressively can send fifty beacons at once legitimately. Cap the batch rather than the session.
  • CORS preflight costs a round trip. Keep the endpoint simple enough that browsers skip the preflight, or cache it aggressively with Access-Control-Max-Age.
  • Address-based limiting and shared networks. Corporate and mobile carrier egress means thousands of real users behind one address. Set the address limit high, and lean on the session limit.

FAQ

Should the endpoint ever return an error status?

No. The client cannot fix a bad beacon and a retry would only repeat it. Return 204 always, and record the rejection server-side where you can act on it.

Where should route templating happen?

At the edge, before storage. Templating at query time means the raw URL — potentially with identifiers in it — is already written, replicated and backed up.

How do I stop someone forging beacons?

You largely cannot, and for aggregate performance data it rarely matters. Bound the values, cap the rates, and treat a sudden distribution change as suspicious. Signing beacons is possible but the key ships to the browser, so it raises the effort rather than solving it.

What is a healthy rejection rate?

Under half a percent, dominated by shape errors from old clients. A rate above a few percent means the schema and the client have diverged.