Real User Monitoring (RUM): Wir haben 80 % der Probleme erkannt, bevor Nutzer sich beschwerten

Echte RUM-Implementierung. Wir haben Support-Tickets um 80 % reduziert mit Cloudflare RUM und Custom Metrics. Schritt-für-Schritt-Setup mit umsetzbaren Alerts.

Alex Chen
Alex Chen
11. Juni 202611 min read
Auch verfügbar auf:中文

TL;DR: Labortests (Lighthouse, CI-Benchmarks) zeigen, wie Ihre App auf einer Testmaschine funktioniert. Real User Monitoring zeigt, wie Ihre App für echte Nutzer auf deren Geräten, Netzwerken und Standorten funktioniert. RUM erkennt Performance-Probleme, die Labortests niemals finden werden – langsame Verbindungen, Speicherdruck, Ad-Blocker-Interferenz und geografische Varianz.


Labordaten vs. Felddaten

AspektLabor (Lighthouse)Feld (RUM)
UmgebungKontrolliert (Moto G4, langsames 3G)Echte Nutzergeräte
NetzwerkSimulierte DrosselungTatsächliche Verbindungen (5G, 4G, 3G, WiFi)
StandortEinzelner StandortGlobal (330+ Cloudflare-Standorte)
GerätFestes GeräteprofilAlle Geräte und Formfaktoren
StichprobengrößeEinzelner Durchlauf pro PRJeder Seitenaufruf
ErkenntOptimierungspotenzialeTatsächliche Nutzererfahrungs-Probleme
FehltDas, was echte Nutzer erlebenKontrollierter Vergleich

Die Wahrheit: Labordaten zeigen, was Sie reparieren sollten. Felddaten zeigen, was Nutzer tatsächlich erleben. Sie brauchen beides.


RUM-Datenerfassung

Web Vitals-Erfassung einrichten

Web Vitals von echten Nutzern zu erfassen ist der erste Schritt jeder RUM-Implementierung. Die web-vitals-Bibliothek liefert attribut-bewusste Metriken, die nicht nur den Wert angeben, sondern auch die Ursache:

typescript
// src/lib/rum.ts
import { onLCP, onCLS, onINP, onTTFB, onFCP } from "web-vitals/attribution"

type VitalName = "LCP" | "CLS" | "INP" | "TTFB" | "FCP"

interface VitalReport {
  name: VitalName
  value: number
  rating: "good" | "needs-improvement" | "poor"
  id: string
  metadata: Record<string, string>
  deviceType: string
  connectionType: string
}

export function initRUM() {
  const vitals: Array<{ name: VitalName; fn: (metric: any) => void }> = [
    { name: "LCP", fn: onLCP },
    { name: "CLS", fn: onCLS },
    { name: "INP", fn: onINP },
    { name: "TTFB", fn: onTTFB },
    { name: "FCP", fn: onFCP },
  ]

  vitals.forEach(({ name, fn }) => {
    fn((metric) => {
      sendVital({
        name,
        value: metric.value,
        rating: metric.rating,
        id: metric.id,
        metadata: extractAttribution(metric),
        deviceType: getDeviceType(),
        connectionType: getConnectionType(),
      })
    })
  })
}

function extractAttribution(metric: any): Record<string, string> {
  if (metric.attribution) {
    // Nützliche Debugging-Infos extrahieren
    const { element, url, fcp, ...rest } = metric.attribution
    return {
      ...(element && { lcpElement: element.tagName }),
      ...(url && { lcpUrl: url }),
    }
  }
  return {}
}

function getDeviceType(): string {
  const ua = navigator.userAgent
  if (/Mobi|Android/i.test(ua)) return "mobile"
  if (/Tablet|iPad/i.test(ua)) return "tablet"
  return "desktop"
}

function getConnectionType(): string {
  const conn = (navigator as any).connection
  return conn?.effectiveType ?? "unknown"
}

RUM-Daten an das Backend senden

Sobald die Daten erfasst sind, müssen die Vitalmetriken zuverlässig an Ihren Server übermittelt werden. Mit navigator.sendBeacon werden Daten auch gesendet, wenn der Nutzer die Seite verlässt:

typescript
// sendBeacon für zuverlässige Übermittlung verwenden (überlebt Seitennavigation)
function sendVital(report: VitalReport) {
  const payload = {
    ...report,
    pathname: window.location.pathname,
    timestamp: Date.now(),
  }

  if (navigator.sendBeacon) {
    navigator.sendBeacon("/api/vitals", JSON.stringify(payload))
  } else {
    fetch("/api/vitals", {
      method: "POST",
      body: JSON.stringify(payload),
      keepalive: true,
    })
  }
}

Serverseitige Speicherung

Auf der Serverseite wird jeder Metrikbericht mit Kontextmetadaten wie Land und Gerätetyp in D1 gespeichert, um ihn später analysieren zu können:

typescript
// server/rum.ts
export const reportVital = createServerFn({ method: "POST" }).handler(
  async ({ request, context }) => {
    const data = await request.json()

    // In D1 für Abfragen speichern
    await context.env.DB.prepare(`
      INSERT INTO rum_metrics (
        id, name, value, rating, pathname,
        device_type, connection_type,
        country, metadata, created_at
      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    `).bind(
      data.id,
      data.name,
      data.value,
      data.rating,
      data.pathname,
      data.deviceType,
      data.connectionType,
      request.cf?.country ?? "unknown",
      JSON.stringify(data.metadata),
      data.timestamp
    ).run()

    return { received: true }
  }
)

RUM-Daten analysieren

Nach Metrik abfragen

Nachdem genügend Daten gesammelt wurden, fragen Sie diese ab, um Ihre Performance-Basislinie zu verstehen. Diese Dashboard-Funktion berechnet p50-, p75- und p95-Perzentile sowie den Anteil der „guten" Bewertungen für jede Metrik:

typescript
export const getRumDashboard = createServerFn({ method: "GET" }).handler(
  async ({}, { context }) => {
    // Gesamtmetriken für die letzten 7 Tage
    const overall = await context.env.DB.prepare(`
      SELECT
        name,
        COUNT(*) as samples,
        APPROX_PERCENTILE(value, 0.5) as p50,
        APPROX_PERCENTILE(value, 0.75) as p75,
        APPROX_PERCENTILE(value, 0.95) as p95,
        SUM(CASE WHEN rating = 'good' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) as good_pct
      FROM rum_metrics
      WHERE created_at > datetime('now', '-7 days')
      GROUP BY name
    `).all()

    // Aufschlüsselung nach Pfad (Top 20 langsamste)
    const byPath = await context.env.DB.prepare(`
      SELECT
        pathname,
        APPROX_PERCENTILE(CASE WHEN name = 'LCP' THEN value END, 0.5) as lcp_p50,
        APPROX_PERCENTILE(CASE WHEN name = 'INP' THEN value END, 0.5) as inp_p50,
        COUNT(*) as pageviews
      FROM rum_metrics
      WHERE created_at > datetime('now', '-7 days')
      GROUP BY pathname
      ORDER BY lcp_p50 DESC
      LIMIT 20
    `).all()

    return { overall: overall.results, slowestPaths: byPath.results }
  }
)

RUM-Dashboard

RUM Dashboard (Letzte 7 Tage)

Web Vitals Übersicht:
┌────────┬──────────┬──────────┬──────────┬─────────┐
│ Metrik │ P50      │ P75      │ P95      │ % Gut   │
├────────┼──────────┼──────────┼──────────┼─────────┤
│ LCP    │ 1.200 ms │ 2.100 ms │ 4.500 ms │ 78 %    │
│ CLS    │ 0,02     │ 0,05     │ 0,15     │ 85 %    │
│ INP    │ 80 ms    │ 150 ms   │ 350 ms   │ 82 %    │
│ TTFB   │ 150 ms   │ 350 ms   │ 900 ms   │ 88 %    │
└────────┴──────────┴──────────┴──────────┴─────────┘

Performance nach geografischer Region:
┌───────────────┬──────────┬──────────┬───────────────┐
│ Region        │ P50 LCP  │ P95 LCP  │ Langsam % (>3s) │
├───────────────┼──────────┼──────────┼───────────────┤
│ US East       │ 900 ms   │ 2.100 ms │ 3 %          │
│ US West       │ 1.100 ms │ 2.800 ms │ 5 %          │
│ Europa        │ 1.300 ms │ 3.200 ms │ 8 %          │
│ Asien-Pazifik │ 2.100 ms │ 5.500 ms │ 20 %         │
│ Südamerika    │ 2.400 ms │ 6.000 ms │ 25 %         │
└───────────────┴──────────┴──────────┴───────────────┘

Top 5 langsamste Seiten:
1. /dashboard/analytics (p50 LCP: 4,2 s) — schwere Diagramme
2. /products/listing (p50 LCP: 3,8 s) — große Bilder
3. /reports/export (p50 LCP: 3,5 s) — langsame API

Alerts aus RUM-Daten

Automatisierte Alerts verwandeln rohe RUM-Daten in umsetzbare Benachrichtigungen. Diese Funktion prüft die LCP-Qualität und identifiziert durchgehend langsame Seiten:

typescript
export const checkRumAlerts = createServerFn({ method: "GET" }).handler(
  async ({}, { context }) => {
    const alerts = []

    // Alert, wenn Anteil guter LCP unter den Schwellenwert fällt
    const lcpQuality = await context.env.DB.prepare(`
      SELECT
        COUNT(*) as total,
        SUM(CASE WHEN rating = 'good' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) as good_pct
      FROM rum_metrics
      WHERE name = 'LCP' AND created_at > datetime('now', '-1 hour')
    `).first()

    if (lcpQuality && Number(lcpQuality.good_pct) < 70) {
      alerts.push({
        type: "rum_degradation",
        metric: "LCP",
        goodPct: Math.round(Number(lcpQuality.good_pct)),
        threshold: 70,
        severity: "high",
      })
    }

    // Alert, wenn eine bestimmte Seite p95 LCP > 5 s hat
    const slowPages = await context.env.DB.prepare(`
      SELECT pathname, COUNT(*) as views
      FROM rum_metrics
      WHERE name = 'LCP'
        AND value > 5000
        AND created_at > datetime('now', '-1 hour')
      GROUP BY pathname
      HAVING views > 10
      ORDER BY views DESC
      LIMIT 5
    `).all()

    if (slowPages.results.length > 0) {
      alerts.push({
        type: "slow_pages",
        pages: slowPages.results,
        severity: "medium",
      })
    }

    return alerts
  }
)

RUM zur Steuerung von Optimierungen nutzen

RUM-SignalUntersuchungOptimierung
Hohes LCP auf MobilgerätenHero-Bildgröße prüfenAVIF ausliefern, Hero vorladen, Bildgröße reduzieren
Hohes CLS auf ProduktseiteDynamische Inhaltseinfügungen prüfenPlatz reservieren, Font-Swap-Layoutverschiebung beheben
Hohes INP im DashboardHauptthread-Aktivität profilierenLange Tasks aufteilen, Diagramme verzögert laden
Schlechtes APAC LCPGeografisches LatenzproblemEdge Caching, CDN-Optimierung
Schlechtes TTFB auf Auth-SeitenAuth-Middleware-OverheadSession-Lookup optimieren, Auth-Status zwischenspeichern

RUM-Datenschema

sql
-- migrations/rum_metrics.sql
CREATE TABLE IF NOT EXISTS rum_metrics (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,         -- LCP, CLS, INP, TTFB, FCP
  value REAL NOT NULL,        -- Metrikwert in ms oder Score
  rating TEXT NOT NULL,       -- good, needs-improvement, poor
  pathname TEXT NOT NULL,     -- URL-Pfad
  device_type TEXT,           -- mobile, desktop, tablet
  connection_type TEXT,       -- 4g, 3g, 2g, slow-2g
  country TEXT,               -- Zweistelliger Ländercode
  metadata TEXT,              -- JSON mit Attributionsdaten
  created_at INTEGER NOT NULL
);

CREATE INDEX idx_rum_name ON rum_metrics(name);
CREATE INDEX idx_rum_created ON rum_metrics(created_at);
CREATE INDEX idx_rum_path ON rum_metrics(pathname);

RUM-Implementierungs-Checkliste

  • Web Vitals-Bibliothek auf allen Seiten installiert und initialisiert
  • RUM-Daten per sendBeacon für zuverlässige Übermittlung gesendet
  • Server-Endpunkt speichert Metriken in D1 oder Analytics Engine
  • Sample-Rate konfiguriert (100 % für Erstaufbau, dann auf 10–25 % reduzieren)
  • Dashboard für p50/p75/p95-Metriken aufgebaut
  • Geografische Aufschlüsselung im Dashboard sichtbar
  • Pfadbasierte Aggregation zur Erkennung langsamer Seiten
  • Automatisierte Alerts für RUM-Degradation
  • Gerätetyp-Segmentierung (Mobilgeräte vs. Desktop)
  • Verbindungstyp-Tracking für netzwerk-bewusste Optimierung
  • Integration in CI-Pipeline — PR RUM mit Produktions-RUM vergleichen
  • Historische Datenaufbewahrung für Trendanalyse

Fazit

Real User Monitoring schließt die Lücke zwischen dem, was Sie in der Entwicklung testen, und dem, was Ihre Nutzer in der Produktion erleben. Ohne RUM optimieren Sie auf Basis von Annahmen. Mit RUM basiert jede Optimierungsentscheidung auf Daten echter Nutzer.

Die Implementierung ist straightforward:

  1. Web Vitals bei jedem Seitenaufruf erfassen
  2. In D1 oder Analytics Engine speichern
  3. Dashboards zur Visualisierung aufbauen
  4. Alerts für Degradation einrichten
  5. Daten zur Priorisierung der Optimierungsarbeit nutzen

Um Ihre Performance-Praxis zu vertiefen, lesen Sie unseren Core Web Vitals Optimization Guide für Metrik-Verbesserungsstrategien, Performance Budgeting zur Vermeidung von Regressionen, SaaS Monitoring and Observability für die Infrastruktur-Gesundheit und Lighthouse 95+ Optimization Tips für Labor-basierte Audits.