Setting Up Real User Monitoring (RUM) Technically
AI generated
60fps
ms
Performance · RUM · Web Vitals · Monitoring
Setting Up Real User Monitoring (RUM) Technically
Measuring, shipping, and analyzing Web Vitals straight from production

Synthetic tests only show a simulated snapshot, yet real customers experience Magento and Hyvä stores on wildly different devices, networks, and connection qualities. This article shows, in technical depth, how to integrate the web-vitals library, ship measurements efficiently via sendBeacon, sample sensibly under heavy traffic, build a minimal ingestion backend, and stay privacy compliant throughout.

14 min. read RUM · Web Vitals · Sampling · Privacy web-vitals JS · sendBeacon API · Magento 2.4.8

1. RUM and synthetic monitoring: different questions, different answers

Synthetic monitoring, whether through Lighthouse CI, WebPageTest, or scheduled Puppeteer runs, measures performance in a controlled, reproducible environment with fixed network speed, a fixed device, and no user variability. That makes regressions between two deployments precisely comparable, because nearly every confounding variable is eliminated. Real User Monitoring (RUM), on the other hand, measures exactly the events that real visitors experience in real browsers on real devices and networks, including slow mobile connections, overloaded low-end phones, and background tabs that synthetic tests can never reproduce.

The difference shows up most clearly with LCP and INP: a synthetic test on a reference machine with a stable connection can come back green, while the real p75 value across actual users sits in the red because of weak mobile coverage or older Android devices. Relying only on synthetic data risks optimizing for a scenario almost no customer actually experiences. RUM closes that gap, but it cannot itself provide controlled before-and-after comparisons ahead of a deployment, which is why synthetic monitoring remains indispensable in the CI/CD pipeline.

The most robust strategy combines both: synthetic monitoring as an early-warning system directly in the deployment process, RUM as a continuous production signal that reflects actual user experience across device types, networks, and regions. The following sections focus on the technical build-out of the RUM half of that combination.

2. Integrating the web-vitals library: capturing LCP, INP, CLS, TTFB, and FCP

Google's web-vitals library wraps the fiddly browser APIs (PerformanceObserver, the Layout Instability API, the Event Timing API) behind five simple functions: onLCP, onINP, onCLS, onTTFB, and onFCP. Each function registers a callback that receives a Metric object with name, value, rating (good, needs-improvement, poor), id, and delta as soon as the browser can determine the final value. For CLS and INP that often only happens when the user leaves the page, because both metrics accumulate over the entire session.

The distinction between reporting modes matters: in the default mode, the callback fires once with the final value; in reportAllChanges mode it fires on every change, which is useful for local debugging but generates unnecessary traffic in production. For a RUM setup, register all five metrics right at page load in a lean bootstrap script and forward each value to the shipping function the moment its callback fires, rather than waiting until the page unloads, since TTFB and FCP are often already known seconds before LCP and INP.


import { onLCP, onINP, onCLS, onTTFB, onFCP } from 'web-vitals';

// Collect a metric and forward it as soon as it becomes final
function reportMetric(metric) {
  const payload = {
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    id: metric.id,
    navigationType: metric.navigationType,
    page: location.pathname,
    ts: Date.now()
  };
  sendRumBeacon(payload);
}

onLCP(reportMetric);
onINP(reportMetric);
onCLS(reportMetric);
onTTFB(reportMetric);
onFCP(reportMetric);

3. Shipping beacons with navigator.sendBeacon instead of fetch

navigator.sendBeacon() was designed for exactly this problem: metrics like CLS and INP often only become final once the user is already leaving the page, inside the visibilitychange or pagehide event. A regular fetch() or XHR call at that point is frequently aborted by the browser as soon as the rendering process terminates, because the request isn't guaranteed to keep running in the background. sendBeacon() instead hands the data off asynchronously to the browser process, which guarantees delivery independent of the page's lifecycle, and returns a boolean synchronously indicating whether the request was accepted.

The trade-off: sendBeacon() only supports POST, offers no custom header control beyond the content type via the Blob's MIME type, and is capped at a payload limit of typically 64 KB per origin across all pending beacons. For older browsers or environments without sendBeacon support, fetch() with keepalive: true is the correct fallback: keepalive signals the browser the same behavior, but it doesn't work identically reliably everywhere, which is why sendBeacon() should always be the first choice where available.


// Send the RUM payload reliably, even during page unload
function sendRumBeacon(payload) {
  const url = 'https://rum.mironsoft.de/collect';
  const body = JSON.stringify(payload);

  if (navigator.sendBeacon) {
    const blob = new Blob([body], { type: 'application/json' });
    const queued = navigator.sendBeacon(url, blob);
    if (queued) return;
  }

  // Fallback for browsers without sendBeacon or a rejected queue
  fetch(url, {
    method: 'POST',
    body,
    headers: { 'Content-Type': 'application/json' },
    keepalive: true
  }).catch(() => {
    // Silently drop the event, RUM must never break the page
  });
}

4. Sampling strategy: why 100 percent capture fails at high-traffic scale

A Magento store with 500,000 sessions a month and five metrics per pageload generates several million events monthly at full capture. Ingestion servers, database, and storage costs scale linearly, while the statistical value gained above a certain sample size barely increases. Percentiles like p75 already stabilize with a few thousand samples per page type and time window; every additional event beyond that mostly costs infrastructure without meaningfully improving statistical confidence.

The common solution is a client-side sampling decision made right at page load, before any metrics are even collected: a random value is checked against a configured sample rate, and only on success does the web-vitals library get initialized at all. That saves not just network traffic but also client CPU time, since PerformanceObserver instances are never created for unsampled sessions. Typical sample rates range from 5% at very high traffic to 100% for smaller stores under roughly 10,000 sessions a month.


// Decide once per session whether this visitor is sampled
function isSampled(sampleRate) {
  const key = 'rum_sampled';
  let decision = sessionStorage.getItem(key);

  if (decision === null) {
    decision = Math.random() < sampleRate ? '1' : '0';
    sessionStorage.setItem(key, decision);
  }

  return decision === '1';
}

// Only bootstrap web-vitals collection for sampled sessions
if (isSampled(0.1)) {
  initWebVitalsCollection();
}

5. Stratified sampling by page type

A single sample rate across an entire store ignores the fact that not all pages carry equal weight. The checkout page typically sees only a fraction of the traffic of category pages, yet any performance regression there costs revenue directly. A low sample rate there produces too few data points for reliable p75 values. Category and product pages, by contrast, generate enough traffic that even a low sample rate of 5 to 10 percent delivers sufficient signal.

The practical implementation is a lookup table in the bootstrap script that picks the right sample rate based on a server-injected page_type attribute, for example via a data-page-type attribute on the body tag: 100% for checkout and payment pages, 25% for product detail pages, 5% for category and CMS pages. That keeps total volume manageable while business-critical flows retain full statistical coverage. This mapping should be configurable server-side rather than hardcoded in the JavaScript bundle, so it can be adjusted without a deployment.

6. A minimal ingestion endpoint for RUM data

A RUM backend only needs to reliably do one thing at first: validate incoming beacons, guard against obvious abuse, and persist them efficiently without blocking the request thread on expensive processing. The endpoint should accept POST with a JSON body only, check name against a whitelist (LCP, INP, CLS, TTFB, FCP), hard-cap the payload size, and discard obviously implausible values, such as a negative LCP value or a CLS above 10, instead of writing them into the database unchecked.

A terse 204 No Content with no body is enough for the response, since the browser never inspects the response of a sendBeacon request anyway, and any additional response payload is pure waste. In practice it also pays off to batch writes instead of issuing a single INSERT per event: either through a message queue, such as a Redis list acting as a buffer, or through batch inserts that flush collected events every few seconds in one shot.


<?php
declare(strict_types=1);

// Minimal RUM ingestion endpoint, no framework dependency
header('Content-Type: text/plain');

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit;
}

$raw = file_get_contents('php://input', false, null, 0, 8192);
$payload = json_decode($raw ?: '', true);

$allowedMetrics = ['LCP', 'INP', 'CLS', 'TTFB', 'FCP'];

if (
    !is_array($payload)
    || !in_array($payload['name'] ?? '', $allowedMetrics, true)
    || !is_numeric($payload['value'] ?? null)
    || (float) $payload['value'] < 0
    || (float) $payload['value'] > 60000
) {
    http_response_code(422);
    exit;
}

// Anonymize the visitor IP before it ever reaches storage
$ipParts = explode('.', $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
$ipParts[3] = '0';
$anonymizedIp = implode('.', $ipParts);

$pdo = new PDO('mysql:host=127.0.0.1;dbname=rum', 'rum_writer', getenv('RUM_DB_PASSWORD'));
$stmt = $pdo->prepare(
    'INSERT INTO rum_events (metric_name, value, rating, page_type, page_path, ip_hash, created_at)
     VALUES (:name, :value, :rating, :page_type, :page_path, :ip_hash, NOW())'
);
$stmt->execute([
    'name' => $payload['name'],
    'value' => (float) $payload['value'],
    'rating' => $payload['rating'] ?? 'unknown',
    'page_type' => substr((string) ($payload['page_type'] ?? 'unknown'), 0, 32),
    'page_path' => substr((string) ($payload['page'] ?? '/'), 0, 255),
    'ip_hash' => hash('sha256', $anonymizedIp . getenv('RUM_SALT')),
]);

http_response_code(204);

7. Aggregating into percentiles: p50, p75, and p95

Raw events in a time series are useless for a dashboard until they're aggregated into percentiles. The average is misleading for performance metrics, because individual outliers, such as a user on a very slow connection, skew the mean without reflecting the typical experience. p75 has become the standard because it covers three quarters of all sessions, making it more robust against single extreme values than p50, while still being more sensitive to real regressions than p95.

MySQL 8.0 and later supports PERCENTILE_CONT as a window function directly in SQL, which removes the need for a separate aggregation pipeline for simple dashboards. For larger data volumes, an hourly pre-aggregation into a separate table that the dashboard reads from pays off, instead of scanning millions of raw rows on every query. The raw table can then be purged after 30 to 90 days while the aggregated percentiles are kept permanently.


-- Aggregate p50/p75/p95 per metric and page type for the last 24 hours
SELECT DISTINCT
  metric_name,
  page_type,
  COUNT(*) OVER (PARTITION BY metric_name, page_type) AS sample_count,
  PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY value)
    OVER (PARTITION BY metric_name, page_type) AS p50,
  PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY value)
    OVER (PARTITION BY metric_name, page_type) AS p75,
  PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY value)
    OVER (PARTITION BY metric_name, page_type) AS p95
FROM rum_events
WHERE created_at >= NOW() - INTERVAL 24 HOUR;

8. Privacy: PII-free beacons, IP anonymization, and GDPR

A RUM beacon must never carry personally identifiable data: no email addresses, no customer numbers, no full URL with query parameters that might contain search terms or form input. The payload should be limited to metric name, value, rating, a coarse page type, and the path without a query string, which is enough for any meaningful performance analysis while minimizing the surface for privacy violations.

A visitor's IP address counts as personal data under EU law and must not be stored permanently in plain text. Common practice is either truncating the last octet, as in the ingestion example above, or hashing it with a daily-rotating salt, so no cross-day tracking is possible while abuse detection still works. Because a RUM setup configured this way sets no cookies and enables no cross-device recognition, it can often be operated on the basis of legitimate interest under GDPR Article 6(1)(f) without showing a cookie consent banner, in contrast to third-party analytics tools that typically set persistent client IDs and cross-site cookies for user recognition and therefore require consent.

This assessment is not a substitute for case-by-case legal advice, but the underlying technical rule holds up well: the fewer data points stored per event and the shorter the retention period for raw data, the lower the compliance risk and the easier it is to run the setup in a privacy-compliant way.

9. RUM vs. synthetic monitoring compared side by side

The table below summarizes where RUM and synthetic monitoring each play to their strengths, and where the other method has to fill the gap.

Dimension RUM Synthetic Monitoring Takeaway
Data source Real user sessions Simulated bot runs Combining both is needed for the full picture
Coverage Only actually visited pages Every configured page, even before launch Synthetic fills RUM gaps before go-live
Cost at scale Grows with traffic, needs sampling Fixed cost per check, independent of traffic RUM requires a sampling strategy at scale
Catching real device/network issues Reliably captures real outliers Simulated conditions, misses device diversity RUM is the only source of real user reality
Regression detection latency Needs sample volume, delayed signal Immediate alert after every deployment Synthetic gives fast CI/CD feedback

In practice, the two approaches complement rather than replace each other: synthetic monitoring catches regressions immediately in the deployment process, while RUM shows how those regressions actually affect real customers across devices and networks. A dashboard that surfaces both data sources side by side with the same metric definitions makes lab-versus-field discrepancies visible right away.

Mironsoft

RUM setup, Web Vitals monitoring, and Hyvä performance for Magento stores

Ready to set up Real User Monitoring properly?

We build your RUM setup from the web-vitals integration through the sampling strategy to the ingestion backend, with a percentile dashboard and a configuration that stays privacy compliant without unnecessary consent hurdles.

RUM implementation

web-vitals integration, sendBeacon shipping, and sampling matched to your traffic

Dashboard setup

Ingestion backend, percentile aggregation, and regression alerts

Privacy-compliant configuration

IP anonymization, PII-free payloads, and GDPR-compliant retention periods

10. Summary

Real User Monitoring solves a problem synthetic tests structurally cannot: showing how Magento and Hyvä stores actually perform for real customers on real devices and networks. The web-vitals library delivers measurements for LCP, INP, CLS, TTFB, and FCP directly from the browser, and navigator.sendBeacon() ensures those values still arrive reliably even when the user leaves the page, with fetch(..., { keepalive: true }) as a fallback for edge cases.

Past a certain traffic size, a deliberate sampling strategy stratified by page type is not a nice-to-have but a precondition for keeping the ingestion backend and storage costs manageable. A minimal ingestion endpoint with whitelist validation, batch inserts, and percentile aggregation at p50, p75, and p95 is enough for a meaningful dashboard, as long as PII-free payloads and IP anonymization are consistently applied, which often makes such a setup operable even without a cookie consent banner.

Setting Up Real User Monitoring - The Essentials at a Glance

web-vitals + sendBeacon

Capture LCP, INP, CLS, TTFB, and FCP with web-vitals and ship them reliably via sendBeacon(), with fetch keepalive as a fallback.

Sampling strategy

Match the sample rate to traffic volume (5-100%), stratified by page type: weight checkout higher than category pages.

Minimal backend

A lean ingestion endpoint with whitelist validation, batch inserts, and percentile aggregation (p50/p75/p95) instead of raw-data dashboards.

Privacy

PII-free beacons, IP anonymization, and usually no consent required, unlike cookie-based third-party analytics.

11. FAQ: Implementing Real User Monitoring

1Why use sendBeacon instead of fetch for RUM data?
sendBeacon hands data off asynchronously to the browser process, which guarantees delivery independent of the page lifecycle. fetch/XHR is often aborted during page unload instead.
2How do I choose the right sampling rate?
Depends on traffic: 5-10% at very high volume, 50-100% for smaller stores. Weight business-critical pages like checkout higher via stratification.
3Do I need a cookie consent banner for RUM?
Without cookies, without PII, and with an anonymized IP, RUM can often run on legitimate interest without consent. Case-by-case legal review recommended.
4What differentiates RUM from Google Analytics or similar tools?
Analytics tools typically measure user behavior via persistent client IDs. RUM measures purely technical performance metrics per pageload, without user identity.
5Self-hosted RUM or a third-party tool?
Self-hosted: full data ownership, more operational effort. Third-party: ready faster, but ongoing costs and data flowing to a third party.
6How do I correctly handle bfcache and SPA navigations?
web-vitals fires again on a bfcache restore via the pageshow event. For SPA navigations, give each virtual page its own metric session ID.
7How large can a beacon payload be?
Browsers typically cap sendBeacon at 64 KB per origin. RUM payloads should stay well under 1-2 KB.
8What is a good target for p75 on LCP and INP?
On a p75 basis: LCP under 2.5 s, INP under 200 ms, CLS under 0.1. Use p75 instead of the average, since the average absorbs outliers.
9How long should I retain raw RUM data?
Keep raw events 30-90 days, then condense into percentile aggregates and delete. Reduces storage costs and retention risk.
10Can I combine RUM and synthetic monitoring in one dashboard?
Yes, recommended: show both data sources side by side with the same metric definitions to surface lab-versus-field discrepancies.