RUM Routing: Route on Real-User Latency

License: pro tier and above. See Licensing. Reference: design details, fallback semantics, and endpoint hardening are in docs/reference/RUMRouting.md.

Real-User Measurement (RUM) routing answers DNS queries with the pool member that is fastest for the actual user asking — measured from real visitors' browsers, not from your cluster's health probes. It captures last-mile, ISP peering, and CDN effects that server-side probes can never see.

You need three things:

  1. A small always-on URL on each member the browser can fetch (the RUM URL)
  2. One script tag on your site (the beacon)
  3. The rum step in your service's filter chain

No agents, no SaaS, no extra processes — the beacon is served by gslbd itself and measurements replicate across your cluster automatically.


Installation

There is nothing separate to install. RUM ships inside gslbd from the commit introducing it onward. Verify your deployment has it:

gslbctl rum status --server http://<nexus-api-host>:<port> --key $GSLB_API_KEY

If the command prints a status block, you're ready. Licensed: no means your license tier is below pro — the endpoints will refuse ingest until upgraded.

Cluster deployments: measurements replicate over your existing NATS state sync (gslb_<cluster>_rum KV bucket, created automatically). No NATS config changes are needed. Without NATS, RUM still works — each node just routes on the measurements it received itself.


Step 1 — Give each member a RUM URL

Each member you want measured needs an absolute http(s) URL that:

  • is served by that member (not through a load balancer that could route elsewhere),
  • responds quickly and cheaply (a 204 No Content handler, a tiny static file, or an existing health route),
  • is reachable from your users' browsers (public, valid TLS if https).

A cache-busting query parameter is appended automatically, so responses may be cached upstream without breaking measurements.

Set it on each member using any surface:

CLI:

gslbctl members update <member-id> --name web-01 \
  --rum-url https://web-01.example.com/__rum

API:

curl -X PUT -H "Authorization: Bearer $GSLB_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"rumUrl": "https://web-01.example.com/__rum"}' \
  http://<nexus-api-host>:<port>/api/v1/members/<member-id>

WebUI: topology page → click the member → RUM URL field.

Terraform:

resource "nexus_member" "web01" {
  pool_id    = nexus_pool.web.id
  ip_address = "203.0.113.10"
  port       = 443
  rum_url    = "https://web-01.example.com/__rum"
}

Members without a RUM URL are never measured. They still serve traffic — they just can't win the rum step and are only chosen via its fallbacks.

Example nginx RUM endpoint (on each member):

location = /__rum {
    add_header Access-Control-Allow-Origin *;
    return 204;
}

(The Access-Control-Allow-Origin header is not strictly required — the beacon uses no-cors fetches — but it keeps browser consoles quiet.)

Step 2 — Embed the beacon

Add one script tag to any page your users load:

<script src="https://<nexus-api-host>/rum.js" data-domain="app.example.com" async></script>
  • src — your Nexus API host (the beacon is served by gslbd; use the API's TLS hostname in production).
  • data-domain — the Nexus service domain whose pool should be measured. Defaults to the page's own hostname if omitted.

The beacon waits two seconds after page load (so it never competes with your own assets), times a fetch to each member's RUM URL, and reports the results with navigator.sendBeacon — invisible to the user, a few hundred bytes of traffic per page view.

Step 3 — Add rum to the filter chain

rum is a terminal (selecting) step — it picks one member and ends the chain. Compose it after any narrowing steps:

# CLI
gslbctl services update <service-id> --name app --domain app.example.com \
  --filter-chain geo-ip,rum
# Terraform
resource "nexus_service" "app" {
  name         = "app"
  domain       = "app.example.com"
  pool_id      = nexus_pool.web.id
  filter_chain = ["geo-ip", "rum"]
}

Or in the WebUI: service editor → filter chain → add Real-User Latency (RUM).

What happens per query: the client's subnet (from EDNS Client Subnet, or the resolver address) is matched against fresh measurements (last 30 min).

  1. Measurements cover ≥1 candidate → lowest real-user latency wins.
  2. No coverage for this subnet → falls back to probe-RTT selection (same as the latency step).
  3. No RTT data either → first healthy candidate.

A query never fails because measurement data is missing, and unhealthy members are excluded before the chain runs — failover always beats RUM.


Verifying It Works

Coverage: after the beacon has been live for a few minutes:

gslbctl rum status
Real-User Measurement (RUM) status
────────────────────────────────────────────────────────────
  Licensed:        yes
  Client subnets:  412
  Measurements:    1236

  MEMBER IP                                 SUBNETS COVERING
────────────────────────────────────────────────────────────
  203.0.113.10                              398
  198.51.100.7                              401

Per-query decisions: trace a resolution from a specific client:

gslbctl trace <service-id> --client-ip 203.0.113.55

The trace shows whether the rum step selected on real-user data (lowest real-user latency for client subnet) or fell back to probe RTT. The same trace is available in the WebUI topology page's Trace panel.

Manual ingest test (simulates one beacon report from your machine):

curl -X POST "http://<nexus-api-host>:<port>/api/v1/rum/ingest" \
  -H 'Content-Type: application/json' \
  -d '{"domain":"app.example.com","samples":[{"ip":"203.0.113.10","ms":42}]}'
# → 204; gslbctl rum status now shows 1 subnet

Tutorial: Lab Walkthrough (10 minutes)

Goal: watch a DNS answer change based on real-user measurements, on a single-node lab install.

  1. Create a pool with two members, both with RUM URLs:

    gslbctl pools create --name rum-lab
    gslbctl members add --pool <pool-id> --name a --ip 203.0.113.10 --port 443 \
      --rum-url https://a.lab.example/__rum
    gslbctl members add --pool <pool-id> --name b --ip 198.51.100.7 --port 443 \
      --rum-url https://b.lab.example/__rum
  2. Create a service using the rum step:

    gslbctl services create --name rumlab --domain rumlab.lab.example \
      --pool <pool-id> --filter-chain rum
  3. Baseline — with no measurements, the step falls back:

    dig @<nexus-host> rumlab.lab.example A +short
    gslbctl trace <service-id> --client-ip <your-ip>   # note: "no RUM coverage"
  4. Report a fake measurement claiming member b is faster for your subnet:

    curl -X POST http://<nexus-host>:<port>/api/v1/rum/ingest \
      -H 'Content-Type: application/json' \
      -d '{"domain":"rumlab.lab.example","samples":[{"ip":"198.51.100.7","ms":12},{"ip":"203.0.113.10","ms":180}]}'

    (Run this from the machine whose subnet you'll query from — samples are keyed to the sender's /24.)

  5. Query again — answers from your subnet now prefer 198.51.100.7, and the trace shows selected on the rum step. Clients on other subnets are unaffected. The measurement expires after 30 minutes.


Operational Notes

  • Privacy: Nexus never stores individual client IPs — only /24 (IPv4) or /48 (IPv6) subnet aggregates, the same granularity as EDNS Client Subnet.
  • Trust model: the ingest endpoint is public (browsers must reach it), but samples are only accepted for members that actually belong to the claimed domain's pool and have a RUM URL; rates are limited per source IP; and a malicious sender can only influence routing for its own subnet.
  • ECS matters for precision: if your users' resolvers don't send EDNS Client Subnet, query-time matching uses the resolver's address. Beacon data is keyed by the user's real subnet, so resolver-only matching reduces (but does not break) coverage — public resolvers like Google/Cloudflare send ECS.
  • Memory: the store holds at most 50,000 client subnets per node and evicts stale entries automatically; no persistence, no database growth.

Was this article helpful?
© 2026