SpeedCurve starts at $20/month for a handful of sites and quickly climbs into triple digits once you add real traffic volumes. Datadog RUM? Don’t get me started. You’re essentially paying a subscription to see Core Web Vitals data that your own infrastructure is generating. That’s a bad deal, especially when the open-source ecosystem has matured enough to handle this properly.
This guide walks through setting up production-ready Real User Monitoring on your own infrastructure. No SaaS lock-in, no per-session pricing, no vendor deciding what data you can access.
What RUM Actually Gives You (vs. Synthetic)
Before jumping into tools, it’s worth being precise about what you’re capturing. Synthetic monitoring (Lighthouse CI, WebPageTest) runs scheduled tests from known locations against your site. It’s consistent, reproducible, and great for catching regressions. But it lies to you about what real users experience.
RUM captures the actual browser metrics from actual visitors: their network conditions, their devices, their geographic latency to your CDN edge. A user in Jakarta on a 4G connection hitting your EU-hosted app will show you numbers that no synthetic test from a Frankfurt data center will ever surface.
The core metrics worth tracking:
- LCP (Largest Contentful Paint) — perceived load speed
- FID / INP (Interaction to Next Paint, replaced FID in March 2024) — responsiveness
- CLS (Cumulative Layout Shift) — visual stability
- TTFB (Time to First Byte) — server + network latency
- FCP (First Contentful Paint) — when something first appears
- Resource timing — which assets are slow, which third-party scripts are killing you
A proper RUM setup lets you slice all of this by country, browser, connection type, URL pattern, and deployment version.
The Contenders
Three tools are worth your time for self-hosted RUM in 2026:
Grafana Faro (github.com/grafana/faro-web-sdk) — Purpose-built for frontend observability. Ships as an SDK you embed in your JS, pushes data to Grafana Agent or Alloy, then flows into Loki (logs/events), Tempo (traces), and Prometheus/Mimir (metrics). If you’re already running the Grafana stack, this is the natural choice. First-class Web Vitals support, source map integration, and session tagging.
OpenReplay (github.com/openreplay/openreplay) — Session replay plus performance data. Think FullStory or LogRocket, self-hosted. Captures click heatmaps, network requests, console errors, and performance timelines. Heavier infrastructure footprint than Faro, but unbeatable when you need to watch an actual user struggling through your checkout flow.
highlight.io (github.com/highlight/highlight) — Full-stack observability: error tracking, session replay, logging, and basic RUM. Self-hosted via Docker Compose. Good choice if you want one tool to replace Sentry + LogRocket + basic performance monitoring.
For pure performance monitoring at scale, Grafana Faro wins. For debugging UX issues, OpenReplay. For a batteries-included single-pane-of-glass, highlight.io.
The rest of this guide focuses on Grafana Faro because it integrates cleanly with infrastructure most teams already run, and its data model maps directly to how you’d query Web Vitals in practice.
Setting Up Grafana Faro: The Full Stack
The architecture is straightforward: Faro SDK (browser) → Grafana Alloy (collector) → Loki + Tempo + Prometheus → Grafana dashboards.
The Docker Compose Stack
Create a directory for this — ~/rum-stack/ — and drop in a docker-compose.yml:
version: "3.8"
volumes:
grafana-data:
loki-data:
tempo-data:
prometheus-data:
networks:
rum:
driver: bridge
services:
# Grafana Alloy — the collector that receives Faro SDK data
alloy:
image: grafana/alloy:latest
container_name: alloy
ports:
- "12345:12345" # Alloy UI
- "12347:12347" # Faro receiver endpoint (HTTP)
volumes:
- ./alloy/config.alloy:/etc/alloy/config.alloy:ro
command: run --server.http.listen-addr=0.0.0.0:12345 /etc/alloy/config.alloy
networks:
- rum
restart: unless-stopped
loki:
image: grafana/loki:latest
container_name: loki
ports:
- "3100:3100"
volumes:
- ./loki/config.yaml:/etc/loki/config.yaml:ro
- loki-data:/loki
command: -config.file=/etc/loki/config.yaml
networks:
- rum
restart: unless-stopped
tempo:
image: grafana/tempo:latest
container_name: tempo
ports:
- "3200:3200" # Tempo HTTP API
- "4317:4317" # OTLP gRPC
volumes:
- ./tempo/config.yaml:/etc/tempo/config.yaml:ro
- tempo-data:/tmp/tempo
command: -config.file=/etc/tempo/config.yaml
networks:
- rum
restart: unless-stopped
prometheus:
image: prom/prometheus:latest
container_name: prometheus
ports:
- "9090:9090"
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus-data:/prometheus
networks:
- rum
restart: unless-stopped
grafana:
image: grafana/grafana:latest
container_name: grafana
ports:
- "3000:3000"
volumes:
- grafana-data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning:ro
environment:
- GF_SECURITY_ADMIN_PASSWORD=changeme # change this
- GF_FEATURE_TOGGLES_ENABLE=traceqlEditor
networks:
- rum
restart: unless-stopped
depends_on:
- loki
- tempo
- prometheus
Grafana Alloy Configuration
Create alloy/config.alloy. Alloy is the modern replacement for Grafana Agent — it uses a River-based config language that’s more expressive than YAML:
// Faro receiver — accepts data pushed by the browser SDK
faro.receiver "rum_collector" {
server {
listen_address = "0.0.0.0"
listen_port = 12347
cors_allowed_origins = ["*"] // Tighten this in production!
}
// Ship logs/events to Loki
output {
logs = [loki.write.default.receiver]
traces = [otelcol.exporter.otlp.tempo.input]
}
extra_log_labels = {
app = "faro-rum",
}
}
// Write logs to Loki
loki.write "default" {
endpoint {
url = "http://loki:3100/loki/api/v1/push"
}
}
// Forward traces to Tempo via OTLP
otelcol.exporter.otlp "tempo" {
client {
endpoint = "tempo:4317"
tls {
insecure = true
}
}
}
Loki Configuration
loki/config.yaml — minimal but functional:
auth_enabled: false
server:
http_listen_port: 3100
common:
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
kvstore:
store: inmemory
schema_config:
configs:
- from: 2020-10-24
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
limits_config:
# Faro sends structured log lines — raise ingestion rate for high-traffic sites
ingestion_rate_mb: 16
ingestion_burst_size_mb: 32
Tempo Configuration
tempo/config.yaml:
server:
http_listen_port: 3200
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
storage:
trace:
backend: local
local:
path: /tmp/tempo/blocks
wal:
path: /tmp/tempo/wal
compactor:
compaction:
block_retention: 72h # 3 days — tune based on your disk budget
metrics_generator:
registry:
external_labels:
source: tempo
storage:
path: /tmp/tempo/generator/wal
remote_write:
- url: http://prometheus:9090/api/v1/write
send_exemplars: true
Grafana Data Source Provisioning
grafana/provisioning/datasources/datasources.yaml:
apiVersion: 1
datasources:
- name: Loki
type: loki
url: http://loki:3100
access: proxy
isDefault: false
- name: Tempo
type: tempo
url: http://tempo:3200
access: proxy
jsonData:
tracesToLogs:
datasourceUid: loki
tags:
- key: app
lokiSearch:
datasourceUid: loki
serviceMap:
datasourceUid: prometheus
- name: Prometheus
type: prometheus
url: http://prometheus:9090
access: proxy
isDefault: true
Start the stack:
docker compose up -d
Integrating the Faro SDK Into Your Frontend
Install the SDK:
npm install @grafana/faro-web-sdk @grafana/faro-web-tracing
Initialize it early — ideally before your app framework mounts. For a plain JS/TS project:
import { initializeFaro, getWebInstrumentations } from '@grafana/faro-web-sdk';
import { TracingInstrumentation } from '@grafana/faro-web-tracing';
const faro = initializeFaro({
url: 'http://your-server:12347/collect', // Alloy Faro receiver
app: {
name: 'my-frontend',
version: __APP_VERSION__, // inject at build time — critical for correlating deploys
environment: import.meta.env.MODE,
},
instrumentations: [
// Captures Web Vitals (LCP, INP, CLS, FCP, TTFB) automatically
...getWebInstrumentations({
captureConsole: true,
captureConsoleDisabledLevels: [],
}),
// Distributed tracing — links frontend spans to backend traces
new TracingInstrumentation({
instrumentationOptions: {
propagateTraceHeaderCorsUrls: [
new RegExp(`${import.meta.env.VITE_API_URL}.*`),
],
},
}),
],
});
// Optionally attach user context after auth
faro.api.setUser({
id: user.id,
// Don't send PII without consent
});
For Next.js, initialize this in _app.tsx or a client component that loads before navigation events fire. The SDK hooks into the PerformanceObserver API automatically — no manual instrumentation needed for Web Vitals.
Building the Dashboard
Once data flows in, the Grafana Explore tab lets you write LogQL queries against Faro’s structured log format:
# Web Vitals for LCP — filter to real users, exclude bots
{app="faro-rum"}
| json
| type = "measurement"
| measurement_type = "webVitals"
| line_format "{{.measurement_values_LCP}}"
For a proper dashboard, import Grafana’s official Faro dashboards from the dashboard catalog. Dashboard IDs 22209 and 22210 cover Web Vitals and error rates respectively — save yourself the 2 hours of panel config.
Gotchas
CORS will bite you immediately. The Faro collector needs to accept requests from your frontend’s origin. The cors_allowed_origins = ["*"] in the Alloy config above is fine for local testing, but in production lock it down to your actual domain(s). If you forget, you’ll see CORS errors in DevTools and zero data in Grafana.
Don’t expose Alloy’s port directly to the internet. Put Nginx or Caddy in front of it with TLS. The Faro endpoint receives raw browser data — you don’t want it crawlable or abusable. Rate-limit at the proxy layer too; a misconfigured SDK could flood your collector.
Version tagging is non-negotiable. If you don’t inject a build version into app.version, every performance regression becomes a debugging nightmare. Correlate deploys with metric changes by baking the git SHA or semver into the bundle at build time. In Vite: define: { __APP_VERSION__: JSON.stringify(process.env.npm_package_version) }.
Loki storage grows fast. Faro emits an event for every route change, every user interaction if you enable it, plus Web Vitals measurements. Set a retention policy from day one — the limits_config in Loki supports retention_period. For a site doing 50K daily active users, expect 2-5 GB/day of raw log data before compression.
INP replaced FID. If you’re reading old RUM documentation, FID (First Input Delay) was deprecated. The Faro SDK measures INP (Interaction to Next Paint) automatically as of v1.3+. Don’t build dashboards around FID for new deployments.
Source maps. Without source maps, JavaScript errors show you minified stack traces. Faro supports uploading source maps to Grafana — configure this in your CI pipeline or you’ll spend time manually mapping line numbers.
OpenReplay: When You Need to Watch the Replay
Grafana Faro gives you metrics and traces. OpenReplay gives you video.
If your conversion funnel is broken and the metrics tell you users are rage-clicking without knowing where, OpenReplay is what you want. It reconstructs the DOM state on each event so you can literally replay what a user saw.
Quick Docker Compose deploy from their repo:
git clone https://github.com/openreplay/openreplay
cd openreplay/scripts/docker-compose
cp .env.example .env
# Edit .env: set DOMAIN_NAME, POSTGRES_PASSWORD, REDIS_PASSWORD
docker compose up -d
OpenReplay needs a domain with HTTPS — it won’t work over plain HTTP for the session capture to function correctly. It’s heavier (PostgreSQL, Redis, MinIO, Kafka), so allocate at least 4 GB RAM and 4 cores.
The SDK integration is similar to Faro:
import OpenReplay from '@openreplay/tracker';
const tracker = new OpenReplay({
projectKey: 'YOUR_PROJECT_KEY', // from OpenReplay dashboard
ingestPoint: 'https://your-openreplay.domain/ingest',
capturePerformance: true, // enables Web Vitals capture
});
tracker.start();
Use both Faro and OpenReplay together if the budget allows — Faro for aggregate metrics and alerting, OpenReplay for surgical debugging of specific user paths.
Production-Ready Checklist
Before going live, run through these:
Security:
- TLS on every public endpoint (Alloy receiver, OpenReplay ingest)
- CORS locked to your actual domain list
- Alloy UI (port 12345) not exposed publicly
- Rate limiting at the reverse proxy layer
Data hygiene:
- Strip PII before it hits Faro — don’t send email addresses or tokens in user context
- Review what
captureConsole: trueactually captures; console logs sometimes contain auth tokens during development - Check your GDPR/privacy policy covers RUM data collection if you operate in the EU
Scaling:
- Single Loki instance is fine to ~500K events/day; beyond that, switch to Loki in microservices mode or consider Grafana Cloud for just the storage tier
- Tempo’s local storage needs regular compaction — the config above handles this, but monitor disk usage
- Alloy is stateless and horizontally scalable; put it behind a load balancer for high-traffic sites
Alerting:
- Set Grafana alerts on P75 LCP > 4s and P75 CLS > 0.25 — these are Google’s "needs improvement" thresholds
- Alert on error rate spikes (Faro logs JavaScript errors automatically)
- Alert on INP degradation after deploys
The Bottom Line
SpeedCurve and Datadog RUM are polished products. But you’re paying for someone else’s infrastructure to store and query data that originates in your users’ browsers. Grafana Faro, backed by the LGTM stack (Loki, Grafana, Tempo, Mimir), gives you equivalent observability at the cost of a $10/month VPS and an afternoon of configuration.
The tradeoff is real: you own the ops burden. Disk fills up, Loki configs need tuning, Alloy will restart at inopportune moments. But for any team that’s already running Kubernetes or Docker on their own infrastructure, this is not materially different from what you’re already managing.
The SpeedCurve money can go toward a faster CDN edge instead. That actually moves your LCP numbers.