Vibe monitoring with Last9 MCP: Ask your agent to fix production issues! Setup →
Last9 Last9

How to Set Up Real User Monitoring

Set up Real User Monitoring (RUM) with safe defaults, proper sampling, and consent handling,without breaking your production code.

Jul 21st, ‘25
How to Set Up Real User Monitoring
See How Last9 Works

Unified observability for all your telemetry. Open standards. Simple pricing.

Talk to us

Synthetic monitoring provides consistent, repeatable results, 2.1s load times, passing Lighthouse scores, and minimal variability. But those numbers reflect lab conditions. On slower networks, like 3G in Southeast Asia, real users may see much higher load times, 5.8s or more.

This isn’t a fault of the tools. It’s a difference in testing context. Synthetic tests run on fast machines, stable connections, and clean environments. User traffic comes with device limitations, network latency, and unpredictable browser states.

TL;DR: Synthetic monitoring shows how your application performs in a controlled setup. Real User Monitoring (RUM) captures how it behaves in production. Both are important, but RUM highlights performance issues that synthetic tests can miss. Here’s how to implement RUM efficiently without adding unnecessary overhead.

The Lab vs. Reality Problem

Performance testing environments are typically clean and predictable:

  • Modern machines with high CPU and memory availability
  • Stable, low-latency networks (fiber or Ethernet)
  • Minimal browser overhead, no extensions, fresh sessions
  • Proximity to test servers or CDNs

In contrast, actual user environments are far more variable:

  • Devices with limited compute resources or aging hardware
  • Fluctuating 3G/4G mobile networks, often with high latency or packet loss
  • Browsers running multiple tabs, extensions, and background tasks
  • Geographic distance from your infrastructure or cache layers

This disparity introduces blind spots. Optimizations that pass in lab conditions may not translate to real-world performance. Metrics might look healthy in synthetic tests, while users still encounter input lag, long time-to-interactive, or partial page loads.

💡
To understand where synthetic monitoring fits in alongside RUM, here's a quick breakdown of how they compare.

What RUM Does

Real User Monitoring (RUM) captures performance data directly from user browsers as they interact with your application. Instead of relying on test scripts or lab setups, it measures how real users experience your site, across devices, network types, and geographies.

It helps track key Web Vitals like:

  • Time to First Byte (TTFB): How quickly the server responds
  • First Contentful Paint (FCP): When the browser renders the first visible element
  • Largest Contentful Paint (LCP): When the main content is fully rendered
  • Cumulative Layout Shift (CLS): Visual stability during load
  • Interaction to Next Paint (INP): Input delay during user interaction

But in production, it also means handling:

  • Sampling: Avoid collecting data on every session. Define who and what to track
  • Privacy: Ensure user identifiers or IPs are anonymized or consented where required
  • Performance overhead: Instrumentation should be lightweight and non-blocking
  • Failure handling: RUM data must be queued or retried safely without impacting the user

Done right, RUM gives you visibility into real-world performance without introducing new issues.

Privacy Requirements in RUM

RUM tools operate within the browser, which means they can collect user behavior data. To stay compliant with privacy regulations like GDPR or CCPA, you must ensure consent is obtained before initializing any analytics logic.

// Check for consent before initializing RUM
function initRUMWithConsent() {
  if (!hasUserConsent('analytics')) {
    console.log('RUM disabled - no user consent');
    return;
  }

  try {
    initRUM({
      apiKey: process.env.REACT_APP_RUM_API_KEY,
      anonymizeIP: true,
      respectDNT: true,         // Honor Do Not Track headers
      dataRetention: '30d'      // Limit data retention to 30 days
    });
  } catch (error) {
    console.warn('RUM initialization failed:', error);
  }
}

Collecting data without consent can violate legal policies. The code above ensures RUM activates only when analytics tracking is permitted and privacy headers are respected.

Sampling to reduce runtime overhead

Every RUM script adds some processing and network cost. Instrumenting 100% of user sessions can increase page load times and inflate resource usage. Sampling helps control this by recording only a subset of sessions.

// Apply sampling to limit overhead
const RUM_SAMPLE_RATE = process.env.NODE_ENV === 'production' ? 0.1 : 0;

function shouldSampleUser() {
  return Math.random() < RUM_SAMPLE_RATE;
}

if (shouldSampleUser()) {
  initRUM({
    apiKey: process.env.REACT_APP_RUM_API_KEY,
    enableInDev: false,
    bufferSize: 50,            // Batch events before sending
    flushInterval: 10000       // Send data every 10 seconds
  });
}

At scale, sampling reduces network calls, lowers storage costs, and minimizes client-side impact. A 10% rate is often enough to detect trends without overwhelming systems or affecting load times.

Defensive initialization and event queuing

RUM should never interfere with application logic. If it fails to initialize, the app should continue to work as expected. Wrapping it in a safe client ensures data collection is best-effort, not mission-critical.

// Wrap RUM in a fail-safe client
class SafeRUM {
  constructor() {
    this.initialized = false;
    this.queue = [];
  }

  init(config) {
    try {
      this.rum = new RUMClient(config);
      this.initialized = true;

      // Process any queued events
      this.queue.forEach(event => this.rum.record(event));
      this.queue = [];
    } catch (error) {
      console.warn('RUM failed to initialize:', error);
    }
  }

  record(event) {
    if (!this.initialized) {
      this.queue.push(event);
      return;
    }

    try {
      this.rum.record(event);
    } catch (error) {
      console.warn('RUM recording failed:', error);
    }
  }
}

const safeRUM = new SafeRUM();

Network errors, CSP restrictions, or script load failures shouldn’t block functionality. This approach isolates RUM from your core code and ensures graceful degradation when instrumentation fails.

💡
For a breakdown of the key metrics captured in RUM, see this Real User Monitoring (RUM) guide.

Build vs Buy for RUM

It’s possible to build a basic RUM system using native browser APIs like PerformanceObserver. This gives you full control over what you collect and how you store it.

// DIY RUM using PerformanceObserver
function trackCoreWebVitals() {
  // LCP tracking
  new PerformanceObserver((entryList) => {
    const entries = entryList.getEntries();
    const lastEntry = entries[entries.length - 1];
    console.log('LCP:', lastEntry.startTime);
  }).observe({ entryTypes: ['largest-contentful-paint'] });

  // CLS tracking
  let clsValue = 0;
  new PerformanceObserver((entryList) => {
    for (const entry of entryList.getEntries()) {
      if (!entry.hadRecentInput) {
        clsValue += entry.value;
      }
    }
  }).observe({ entryTypes: ['layout-shift'] });
}

This approach works well for experimenting or capturing specific metrics. But it comes with trade-offs that grow quickly at scale.

Trade-offs of building a custom RUM tool

While building RUM gives flexibility, maintaining it in production introduces significant complexity:

  • Limited browser support: Not all metrics are supported consistently (e.g., Safari has gaps in layout shift reporting)
  • No built-in storage or aggregation: You'll need to build your pipeline to collect, store, and query the data
  • No geographic insights: You can’t easily see performance variation across locations or ISPs
  • Difficult correlation with business metrics: Connecting frontend performance to backend or revenue data requires manual work
  • Inconsistent behavior across devices: Edge cases, like background tabs, CPU throttling, or high input latency, require additional handling

Building your RUM stack is possible if you have the engineering resources to manage instrumentation, data pipelines, and performance analysis. Otherwise, managed RUM tools can reduce operational complexity while providing ready-to-use insights.

Practical RUM Implementation

A production-grade setup needs to handle:

  • Performance metrics collection (Web Vitals, INP, TTFB)
  • Custom event tracking (page navigations, API latency)
  • Consent, sampling, and graceful failure
  • Framework integration (e.g., React, Next.js)

Let’s break it down.

Capture Web Vitals Using PerformanceObserver

The browser exposes key performance metrics like LCP (Largest Contentful Paint) and INP (Interaction to Next Paint) via the PerformanceObserver API. These don’t block the main thread and are extendable for future metrics.

trackCoreWebVitals() {
  // Largest Contentful Paint
  new PerformanceObserver((entryList) => {
    const entries = entryList.getEntries();
    const lastEntry = entries[entries.length - 1];
    this.recordMetric('lcp', lastEntry.startTime);
  }).observe({ entryTypes: ['largest-contentful-paint'] });

  // Interaction to Next Paint (INP) for supported browsers
  if ('PerformanceEventTiming' in window) {
    new PerformanceObserver((entryList) => {
      for (const entry of entryList.getEntries()) {
        const delay = entry.processingStart - entry.startTime;
        this.recordMetric('inp', delay);
      }
    }).observe({ entryTypes: ['event'] });
  }
}

This gives you low-overhead collection of core Web Vitals that directly impact user experience.

Track Custom Events That Matter to Your App

Web Vitals are only part of the picture. You also want to track:

  • Route transitions in SPAs
  • API request/response durations
  • User interactions (clicks, hovers, scrolls)
trackCustomEvents() {
  this.trackRouteChanges();        // Page or route-level metrics
  this.interceptFetch();          // Capture API latency
  this.trackUserInteractions();   // Clicks, inputs, etc.
}

These are tightly linked to perceived performance and allow you to debug UI slowdowns tied to real user actions.

Integrate with Next.js (or Any Frontend Framework)

In Next.js, initialize the RUM client once, ideally in pages/_app.jsand let it handle:

  • Sampling (to control data volume)
  • User/session correlation
  • Silent failures (no runtime breaks if RUM is unavailable)
// pages/_app.js
import { useEffect } from 'react';
import { ProductionRUM } from '../lib/rum-client';

const rum = new ProductionRUM();

function MyApp({ Component, pageProps }) {
  useEffect(() => {
    rum.init({
      apiKey: process.env.NEXT_PUBLIC_RUM_API_KEY,
      environment: process.env.NODE_ENV,
      sampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 0,
      userId: pageProps.user?.id
    });
  }, []);

  return <Component {...pageProps} />;
}

This keeps your telemetry modular and out of the critical path, while still capturing valuable client-side performance data.

💡
If you're comparing options for metrics, log analysis and alerting, this blog on log monitoring tools for developers will help.

Real User Monitoring Insights

RUM enables performance insights that are difficult to detect with synthetic testing alone, especially when user experience varies by geography, device tier, or network quality.

Regional Load Time and Error Rate Variation

RUM data helps detect regional disparities in user experience:

const regionPerformance = {
  'us-east-1': { p95_lcp: 1.8, error_rate: 0.2 },
  'eu-west-1': { p95_lcp: 2.4, error_rate: 0.3 },
  'ap-southeast-1': { p95_lcp: 4.1, error_rate: 1.2 }
};

Slower LCP and higher error rates in Asia-Pacific regions may indicate misconfigured CDN edges or localized infrastructure issues.

Device Tier and Frontend Latency

Grouping data by device capability reveals performance gaps:

const deviceTiers = {
  high_end: { devices: 'iPhone 13+, Pixel 6+', avg_lcp: 1.2 },
  mid_tier: { devices: 'iPhone 8, Galaxy A50', avg_lcp: 2.8 },
  low_end: { devices: 'Budget Android <2GB RAM', avg_lcp: 6.4 }
};

Lower-tier devices often struggle with modern JavaScript bundles and image-heavy layouts, resulting in degraded LCP.

Network Conditions and Load Performance

Real user data also captures the impact of varying network conditions:

const connectionPerformance = {
  'wifi': { avg_lcp: 1.4, users: '45%' },
  '4g': { avg_lcp: 2.8, users: '35%' },
  '3g': { avg_lcp: 5.2, users: '15%' },
  'slow-2g': { avg_lcp: 12.1, users: '5%' }
};

Users on 3G or slower networks experience load times several times higher than those on Wi-Fi, something synthetic benchmarks rarely surface.

Cost and Data Management

Real User Monitoring can generate a significant volume of telemetry. Left unchecked, it can lead to ballooning storage bills, noisy dashboards, and unplanned infra costs. Planning for volume, retention, and ingestion strategy is critical, especially when operating at scale.

What to Expect at Scale

Let’s break down typical data costs for a moderately active frontend:

  • Data volume:
    10,000 daily active users × 10% sample rate × 50 events/session ≈ 50,000 events/day
  • Storage needs:
    With a 30-day retention policy, you’re looking at ~1.5 million events/month
  • Network overhead:
    Each RUM beacon is ~2KB. That’s ~100MB/month for 50k daily events

These are rough figures, but they add up quickly. And this is before factoring in downstream indexing, dashboards, or analytics queries.

Optimization Strategies

To keep data volume and cost in check, you don’t need to collect everything from everyone. Instead, sample more intelligently:

Sample by user type:
Enterprise users may need 100% visibility, but anonymous or casual sessions can be sampled at a much lower rate.

function getSampleRate(user) {
  if (user.tier === 'enterprise') return 1.0;
  if (user.tier === 'premium') return 0.5;
  if (user.isNewUser) return 0.8;
  return 0.1;
}

Sample by performance:
When things are slow, increase sample rate. When they’re fast, reduce it.

function getAdaptiveSampleRate(currentLCP) {
  if (currentLCP > 4000) return 1.0;
  if (currentLCP > 2500) return 0.5;
  return 0.1;
}

This kind of adaptive sampling helps balance cost efficiency with visibility, so you don’t miss regressions when they start spreading.

CI/CD Integration for Performance Regression Detection

You can plug performance checks into your deployment pipeline to prevent regressions from reaching production.

This example GitHub Actions workflow compares the 95th percentile Largest Contentful Paint (LCP) metric between your production environment and the preview deployment for each pull request. It fails the build if the preview branch is slower than a defined threshold.

Example: .github/workflows/performance-regression.yml

name: Performance Regression Check

on:
  pull_request:
    branches: [main]

jobs:
  performance-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Wait for Deployment
        run: sleep 60

      - name: Baseline Performance Data
        id: baseline
        run: |
          BASELINE_LCP=$(curl -H "Authorization: Bearer ${{ secrets.RUM_API_TOKEN }}" \
            "https://api.last9.io/v1/metrics/lcp?environment=production&timerange=24h&branch=main&p95=true")
          echo "baseline_lcp=$BASELINE_LCP" >> $GITHUB_OUTPUT

      - name: Current Performance Data
        id: current
        run: |
          CURRENT_LCP=$(curl -H "Authorization: Bearer ${{ secrets.RUM_API_TOKEN }}" \
            "https://api.last9.io/v1/metrics/lcp?environment=preview&timerange=1h&commit=${{ github.sha }}&p95=true")
          echo "current_lcp=$CURRENT_LCP" >> $GITHUB_OUTPUT

      - name: Check Regression
        run: |
          baseline=${{ steps.baseline.outputs.baseline_lcp }}
          current=${{ steps.current.outputs.current_lcp }}
          threshold=$(echo "$baseline * 1.2" | bc)

          if (( $(echo "$current > $threshold" | bc -l) )); then
            echo "Performance regression detected"
            echo "Baseline LCP: ${baseline}ms"
            echo "Current LCP: ${current}ms"
            echo "Threshold: ${threshold}ms"
            exit 1
          fi

What this setup does:

  • Waits for the preview environment to deploy.
  • Queries the 95th percentile LCP from the past 24 hours on the production branch.
  • Queries LCP for the current commit on the preview environment.
  • Calculates a threshold (120% of production LCP) and fails the PR if the preview LCP exceeds it.

You can swap lcp with any other metric you track via your RUM pipeline, such as ttfb, fcp, or custom timers based on your instrumentation. This workflow acts as a safeguard against performance regressions slipping through during CI.

💡
If your frontend talks to a Java backend, tracking JVM metrics can help you spot upstream issues affecting user experience.

The Honest Caveats

There are a few limitations worth understanding before you rely on it for full coverage.

You Still Need Synthetic Monitoring

RUM only captures what actual users trigger in production. It has no visibility into unvisited code paths or pre-deployment regressions. Synthetic monitoring fills this gap by simulating traffic and validating key flows.

Use it for:

  • Regression checks during CI/CD: RUM doesn’t help during staging or pre-release. Synthetic tests can catch slowdowns before code merges.
  • Critical flow validation: Synthetic flows continuously test login, checkout, onboarding, etc.—even during traffic lulls.
  • Controlled behavior testing: Simulate specific devices, network types, or geos—useful for debugging or reproducing edge conditions.
  • Availability checks: If no users are online, RUM produces no signals. Synthetic uptime checks ensure something is always watching.

Blind Spots You Can’t Avoid

RUM comes with built-in gaps. Even with good sampling and tagging, you’ll miss some data:

  • Unvisited features don’t get monitored. If a feature is broken but unused, RUM won’t report it.
  • Sampling drops rare bugs. Adaptive sampling helps with cost, but some edge cases never get collected.
  • Blocked scripts. Ad blockers and privacy tools can prevent your RUM SDK from running at all.
  • Privacy opt-outs. If a user declines tracking (e.g., via GDPR banners), you won’t get their data—by design.

Technical and Operational Overhead

RUM requires continuous upkeep. It’s not just a script you add once and forget.

  • Data volume grows fast. Without sampling or TTL limits, costs can spike with traffic.
  • SDK and schema drift. Web apps evolve, and so do RUM SDKs. Instrumentation can silently break without tests or alerting.
  • Cross-browser quirks. Not all browsers expose the same performance APIs. Some metrics are missing or behave differently.
  • Privacy controls. You need proper consent handling, redaction, and retention settings, especially in regulated environments.

Getting Started with Minimal Setup

A focused, production-safe setup can help you start collecting meaningful data without overwhelming your system or your team.

Start by instrumenting a single, business-critical flow. This could be your signup, login, or checkout journey.

Here’s what your initial setup might look like:

  • Sample traffic conservatively
    Start with a 5–10% sampling rate. It’s enough to catch broad trends without racking up storage or cost. Adjust based on data volume and variability.
  • Implement consent logic early
    If you're subject to GDPR or CCPA, handle user opt-in at the beginning. Retroactive fixes are messy and risky.
  • Ensure fail-safe behavior
    Instrumentation should never interfere with the page experience. If the SDK fails to load or initialize, your site should still work perfectly.
  • Tie performance to business outcomes
    Tag traces or sessions with metadata like user type, experiment group, or funnel stage. This helps correlate slowdowns with user drop-offs or conversion issues.

Sample RUM Initialization (Safe Mode)

Use dynamic imports, consent checks, and silent error handling to keep your production code resilient:

if (process.env.NODE_ENV === 'production' && userConsent && Math.random() < 0.1) {
  import('@last9/rum-sdk')
    .then(({ initRUM }) => {
      initRUM({
        apiKey: process.env.REACT_APP_RUM_API_KEY,
        environment: 'production',
        onError: () => {} // Ignore SDK errors silently
      });
    })
    .catch(() => {}); // Do not crash app if SDK fails to load
}

This pattern ensures:

  • RUM is only active in production
  • Traffic is sampled to reduce noise and cost
  • No data is collected without user consent
  • SDK failures never break your UI

When used deliberately on key paths, with proper sampling, and safe defaults, RUM becomes a low-risk, high-value part of your observability toolkit.

Last9 RUM gives you frontend performance visibility with minimal configuration and strong production safety guarantees. It’s built for teams that want real user monitoring without compromising stability or compliance.

Some core capabilities include:

  • Built-in Core Web Vitals – Capture metrics like LCP, FID, and CLS out of the box.
  • Path-level granularity – Understand performance at the route or feature level to catch regressions early.
  • Custom tagging support – Annotate sessions with metadata like user type, experiment cohort, or deployment ID to align performance with business context.
  • Production safety – Uses dynamic imports, consent checks, and silent failure handling so the SDK never breaks your UI.
Last9 Review
Last9 Review

Start small by instrumenting a business-critical flow, like login or checkout, and expand coverage as needed. Our Last9 RUM documentation includes a safe initialization pattern to help you get started with correct sampling, consent gating, and failure isolation.

Authors
Anjali Udasi

Anjali Udasi

Helping to make the tech a little less intimidating. I

Contents

Do More with Less

Unlock high cardinality monitoring for your teams.