RUM Ingestion Endpoint on Cloudflare Workers

You have web-vitals firing in the browser and you need an endpoint that accepts the resulting beacons from every continent in single-digit milliseconds, validates them before they ever touch storage, and forwards them to a durable sink without making the browser wait. A Cloudflare Worker is close to the ideal substrate for this: it runs at the edge near the user, terminates TLS for you, scales to zero, and exposes ctx.waitUntil() so you can return 204 No Content to the browser the instant the payload is parsed while the slow write to your warehouse continues in the background. This page is a complete, runnable build of that receiver, and it is the collector half of self-hosted beacon collection — read the parent first if you have not yet decided what your beacon contract looks like.

The scenario is narrow and specific: requests arrive from navigator.sendBeacon(), which means you cannot assume application/json, you cannot read a response in the browser, and you must treat every field as hostile. We handle the awkward text/plain content type, clamp out-of-range metric values, strip the client IP before it persists, apply lightweight per-IP rate limiting, answer CORS preflights, and only then forward to a Queue, Analytics Engine, or ClickHouse.

Cloudflare Worker beacon ingestion flow A sendBeacon request hits the Worker, which checks CORS and rate limits, parses and validates the payload, returns 204 immediately, then forwards the cleaned event to a queue or warehouse using waitUntil. Browser sendBeacon Cloudflare Worker 1. CORS / preflight 2. Rate limit 3. Parse text/json 4. Validate / clamp 5. Strip IP 6. Respond 204 7. waitUntil forward 204 to browser (no body, fast) Queue / AE ClickHouse
The Worker answers the browser the moment a valid payload is parsed; the warehouse write runs in the background through ctx.waitUntil().

Prerequisites

  • A Cloudflare account with Workers enabled, plus wrangler (v3+) installed and authenticated (npx wrangler login).
  • A beacon emitter in the browser. This page assumes you send vitals with navigator.sendBeacon() as described in Web Vitals API Implementation; that detail determines the content type the Worker must parse.
  • A decided sink. The examples forward to a [Cloudflare Queue], to Analytics Engine, or to a ClickHouse HTTP endpoint. Pick one before you deploy.
  • A sampling decision. Drop or keep events on the client where possible; whatever survives sampling should still be aggregated at p75, as covered in RUM data sampling strategies.
  • A KV namespace for rate-limit counters (npx wrangler kv namespace create RL).

How to build the ingestion endpoint

Step 1 — Scaffold the Worker and its wrangler config

Create the project and a wrangler.toml that binds a KV namespace for rate limiting and a Queue for forwarding. Declaring bindings here is what makes env.RL and env.RUM_QUEUE available inside fetch.

name = "rum-ingest"
main = "src/index.js"
compatibility_date = "2025-01-01"

# Lightweight rate-limit counters (one key per IP per window).
[[kv_namespaces]]
binding = "RL"
id = "<your-kv-namespace-id>"

# Background sink: a Queue absorbs bursts without blocking the response.
[[queues.producers]]
binding = "RUM_QUEUE"
queue = "rum-events"

# Optional: secrets for a ClickHouse HTTP sink instead of a Queue.
# npx wrangler secret put CLICKHOUSE_URL
# npx wrangler secret put CLICKHOUSE_AUTH

Why: Bindings are resolved at deploy time and injected as the env argument, so there are no connection strings in code and no cold-start credential fetch. A Queue between the Worker and your warehouse is the cheapest way to survive traffic spikes and warehouse hiccups.

Step 2 — Answer the CORS preflight before anything else

sendBeacon() with a non-simple content type, or any fetch() POST your fallback uses, triggers a CORS preflight OPTIONS request. If the Worker does not answer it correctly the browser silently drops the real beacon.

const ALLOWED_ORIGINS = new Set([
  "https://www.example.com",
  "https://example.com",
]);

function corsHeaders(origin) {
  const allow = ALLOWED_ORIGINS.has(origin) ? origin : "null";
  return {
    "Access-Control-Allow-Origin": allow,
    "Access-Control-Allow-Methods": "POST, OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type",
    "Access-Control-Max-Age": "86400",
    "Vary": "Origin",
  };
}

Why: Echoing the request Origin only when it is on an allowlist prevents your endpoint from becoming an open beacon sink for other sites. Access-Control-Max-Age caches the preflight for a day so repeat visitors skip the extra round trip. Vary: Origin keeps Cloudflare’s cache from serving one origin’s CORS headers to another.

Step 3 — Rate limit per IP with a KV counter

A beacon endpoint is a public POST target, so you need a cheap brake on abuse that does not add latency on the happy path. Use a fixed-window counter in KV keyed by client IP.

async function isRateLimited(env, ip) {
  const window = Math.floor(Date.now() / 10_000); // 10s window
  const key = `rl:${ip}:${window}`;
  const current = parseInt((await env.RL.get(key)) || "0", 10);
  if (current >= 100) return true; // 100 beacons / 10s / IP
  // expirationTtl auto-cleans old windows; no sweep job needed.
  await env.RL.put(key, String(current + 1), { expirationTtl: 30 });
  return false;
}

Why: A fixed window is intentionally crude — it is one read and one write, both at the edge, and it cannot accidentally block a whole region the way a global counter would. expirationTtl makes stale keys delete themselves. KV is eventually consistent, so this enforces an approximate ceiling, not an exact one; that is the correct trade-off for spam control where a few extra requests do not matter.

Step 4 — Parse the body whatever content type it arrives as

This is the step most home-grown collectors get wrong. navigator.sendBeacon(url, JSON.stringify(data)) sends Content-Type: text/plain;charset=UTF-8, while a Blob with an explicit type sends application/json. Parse defensively for both.

async function readPayload(request) {
  const raw = await request.text();          // works for text/plain AND json
  if (raw.length > 16_384) return null;       // hard cap before JSON.parse
  try {
    const data = JSON.parse(raw);
    return data && typeof data === "object" && !Array.isArray(data)
      ? data
      : null;
  } catch {
    return null;                              // malformed beacon: discard
  }
}

Why: Reading .text() works regardless of the declared content type, so you never depend on the header being honest. Capping the raw length before JSON.parse stops a megabyte of junk from costing you CPU time. Rejecting arrays and primitives means the validator in the next step always receives an object.

Step 5 — Validate and clamp every field

Treat the payload as untrusted. Allowlist the fields you store, coerce types, and clamp metric values into physically sane ranges so a single bad client cannot poison your p75. The thresholds below match the current Google spec.

Field Type Clamp / rule Stored as
name enum one of LCP, INP, CLS, FCP, TTFB string
value number 0 … 60000 (ms); CLS 0 … 10 float
rating enum good | needs-improvement | poor string
id string length ≤ 40, strip non-[\w-] string
url string length ≤ 512, must parse as URL string
navType enum navigate | reload | back-forward | prerender string
const METRICS = new Set(["LCP", "INP", "CLS", "FCP", "TTFB"]);
const RATINGS = new Set(["good", "needs-improvement", "poor"]);

function clamp(n, lo, hi) {
  return Math.min(hi, Math.max(lo, n));
}

function validate(d) {
  if (!METRICS.has(d.name)) return null;
  const value = Number(d.value);
  if (!Number.isFinite(value)) return null;
  const max = d.name === "CLS" ? 10 : 60_000;

  let url = null;
  try { url = new URL(String(d.url)).href.slice(0, 512); } catch { return null; }

  return {
    name: d.name,
    value: clamp(value, 0, max),
    rating: RATINGS.has(d.rating) ? d.rating : "good",
    id: String(d.id || "").replace(/[^\w-]/g, "").slice(0, 40),
    url,
    navType: String(d.navType || "navigate").slice(0, 16),
    ts: Date.now(),
  };
}

Why: An allowlist is the only validation strategy that stays safe as clients evolve — unknown fields are dropped, not stored. Clamping value to a ceiling matters because a single bogus 9-digit LCP would drag a largest contentful paint p75 sideways far more than a missing sample would. The CLS branch uses a different ceiling because, unlike interaction to next paint or time to first byte and first contentful paint, cumulative layout shift is a unitless score, not milliseconds.

Step 6 — Strip and truncate the IP before it persists

The Worker can see the client IP via request.headers.get("CF-Connecting-IP"). You need it for rate limiting, but it must never reach storage in raw form. Truncate it to a coarse network prefix so geography survives but identity does not.

function coarseIp(ip) {
  if (!ip) return null;
  if (ip.includes(":")) {                 // IPv6: keep /48
    return ip.split(":").slice(0, 3).join(":") + "::";
  }
  const o = ip.split(".");                 // IPv4: zero the last octet (/24)
  return o.length === 4 ? `${o[0]}.${o[1]}.${o[2]}.0` : null;
}

Why: Zeroing the final IPv4 octet (and keeping only the IPv6 /48) preserves enough signal to segment p75 by rough region while making it impossible to single out a user. Doing this inside the Worker means the raw IP is discarded at the edge and never written anywhere — the strongest place to enforce it.

Step 7 — Respond 204 fast, forward in the background

Assemble the handler. Return 204 No Content the instant the event is valid, then hand the warehouse write to ctx.waitUntil() so it runs after the response is sent.

export default {
  async fetch(request, env, ctx) {
    const origin = request.headers.get("Origin") || "";
    const cors = corsHeaders(origin);

    if (request.method === "OPTIONS") {
      return new Response(null, { status: 204, headers: cors });
    }
    if (request.method !== "POST") {
      return new Response("Method Not Allowed", { status: 405, headers: cors });
    }

    const ip = request.headers.get("CF-Connecting-IP");
    if (await isRateLimited(env, ip || "unknown")) {
      return new Response(null, { status: 429, headers: cors });
    }

    const raw = await readPayload(request);
    const event = raw && validate(raw);
    if (!event) {
      return new Response(null, { status: 204, headers: cors }); // never 4xx a beacon
    }
    event.net = coarseIp(ip);
    event.country = request.cf?.country || null;

    // Forward without blocking the response.
    ctx.waitUntil(forward(env, event));

    return new Response(null, { status: 204, headers: cors });
  },
};

async function forward(env, event) {
  // Option A: Cloudflare Queue (recommended — absorbs bursts).
  await env.RUM_QUEUE.send(event);

  // Option B: ClickHouse HTTP insert (uncomment to use instead).
  // await fetch(`${env.CLICKHOUSE_URL}/?query=INSERT%20INTO%20rum_events%20FORMAT%20JSONEachRow`, {
  //   method: "POST",
  //   headers: { "Authorization": env.CLICKHOUSE_AUTH },
  //   body: JSON.stringify(event),
  // });
}

Why: A beacon has no consumer waiting on its response, so a body would be wasted bytes — 204 is the right answer. Returning 204 even for an invalid payload (rather than 4xx) avoids retry storms from clients you cannot control, while the dropped event simply never reaches the sink. ctx.waitUntil() is the load-bearing call: it keeps the Worker alive to finish the Queue send after the browser has already moved on, so warehouse latency never appears in the user’s request timing.

Verifying it works

Deploy and replay a realistic beacon, exactly as the browser would send it:

npx wrangler deploy

curl -i -X POST "https://rum-ingest.<your-subdomain>.workers.dev" \
  -H "Origin: https://www.example.com" \
  -H "Content-Type: text/plain;charset=UTF-8" \
  --data '{"name":"LCP","value":2412.5,"rating":"good","id":"v4-abc","url":"https://www.example.com/","navType":"navigate"}'

You should see HTTP/2 204 and an access-control-allow-origin header echoing your origin. Confirm the rest in three places:

  • Live logs: npx wrangler tail and send another beacon — you will see the request with no error, and the Queue send completing after the response.
  • Browser DevTools: load a page, open the Network panel, filter to your endpoint, and confirm the real beacon shows 204 with a sub-50 ms duration. Send a clamped value ("value":999999999) and confirm it still returns 204 but lands stored as 60000.
  • RUM dashboard: once the Queue consumer has written rows, your p75 panels should populate within the consumer’s flush interval. A sudden flat-line at exactly your clamp ceiling is the signal that a client is emitting garbage worth investigating.

Edge cases & gotchas

  • keepalive size limit. The browser caps sendBeacon() / fetch(keepalive) payloads at 64 KB total across in-flight requests. Your 16 KB body cap in Step 4 sits comfortably under it, but if you batch many vitals per beacon you can still hit the browser ceiling and have the send silently fail — batch by count, not unboundedly.
  • Missing Origin on same-origin posts. If you serve the page and the endpoint from the same origin, some browsers omit Origin. Decide explicitly whether a missing origin is allowed; the example above maps it to "null", which blocks cross-site but also blocks legitimate same-origin posts unless you add a host check.
  • KV write latency on the hot path. The rate-limit put is awaited, adding a few milliseconds. Under extreme load, drop the await and let the counter lag — an approximate ceiling is fine for spam control, and ctx.waitUntil(env.RL.put(...)) removes it from the critical path entirely.
  • request.cf is undefined in wrangler dev without --remote. Local development will throw if you read request.cf.country directly; the optional chaining (request.cf?.country) above is deliberate. Test geo logic against a deployed preview.
  • Analytics Engine cardinality. If you forward to Analytics Engine instead of a Queue, remember its blobs are limited and high-cardinality fields like full url will be truncated — store the URL path, not the query string.
  • Double-counting on pagehide + visibilitychange. Clients that flush on both lifecycle events can send the same metric id twice; dedupe downstream on id, not in the Worker, so the response stays fast.

FAQ

Why return 204 instead of 200 with a JSON body?

sendBeacon() discards the response entirely — the browser never reads it. A 204 No Content is the smallest correct reply, saves serialization work, and signals to any debugging proxy that the request succeeded with no payload.

Should the endpoint ever return a 4xx for a bad beacon?

No. Clients you do not control may retry on 4xx, creating a feedback loop of garbage. Validate, drop silently, and answer 204. The only non-204 codes worth sending are 405 for the wrong method and 429 for rate limiting.

Where does sampling belong — the client or the Worker?

Prefer the client, so unsampled beacons never cross the network. If you must sample server-side, do it in the Worker before waitUntil so dropped events cost nothing downstream; either way keep your aggregation at p75 as described in RUM data sampling strategies.