Last Black Friday, our synthetic checks were all green — 200s from every city, TTFB under 80ms, no alerts firing. But our support channel was on fire. Real users in southern Brazil were seeing 6-second paints because a regional ISP was throttling our edge. That disconnect is exactly why you can't pick one monitoring approach for your CDN and call it done. I've learned after a decade of running CDN performance that synthetic and RUM aren't competing tools; they're answering completely different questions.
Why Synthetic Monitoring Still Earns Its Keep on the Edge
Synthetic monitoring is your controlled experiment. You define the URL, the location, the device profile, the network condition, and you run the same test every minute from the same PoPs. That repeatability is its superpower. When I’m rolling out a new cache rule or testing a change to origin shielding, I don't want the noise of real user variability — I want to know if Dallas to Ashburn got 12ms slower after my deploy. Synthetic gives you a clean baseline, which makes it perfect for regression detection, SLA verification, and catching full outages before your users do.
Where I’ve seen teams get synthetic wrong is treating it like real traffic. It isn’t. A synthetic agent in a well-connected AWS region in Frankfurt doesn't behave like a user on a budget Android phone with a congested 4G link in rural India. I remember we once tuned our TCP stack based solely on synthetic data from pristine lab conditions and ended up hurting real-world throughput. The defaults we assumed were safe completely masked the problem, something I now check against guidance in TCP Window Scaling and Its Impact on CDN Throughput: Why Defaults Fall Short. If you only test the happy path, you'll miss how your CDN behaves when the network isn't happy.
That doesn't mean you should ditch it. For CDN work, I use synthetic for three jobs: validating edge health, benchmarking competitors, and running pre-production smoke tests. Keep your synthetic suite small, focused, and run from locations that match your actual user base — not just where it's cheap to run a node. And correlate it with low-level network timing. For accurate baseline RTT from synthetic agents, you need a solid methodology — something like what’s outlined in RTT Measurement Methodology for CDN: Getting Accurate Round-Trip Times is essential, since a poorly timed TCP handshake can throw off your entire TTFB comparison. The HTTP Working Group Specifications are also worth a read if you want to understand exactly what each timing phase should include.
#!/bin/bash
# Simple synthetic CDN check: runs from cron every 60s
# Measures DNS, TCP, TLS, TTFB separately
URL="https://cdn.example.com/__health.gif"
COLO="gru-brazil" # tag your synthetic node location
curl -w "@curl-format.txt" -o /dev/null -s \
-H "Cache-Control: no-cache" \
-H "Pragma: no-cache" \
"$URL?synthetic=$COLO&ts=$(date +%s)"
# curl-format.txt contents:
# time_namelookup: %{time_namelookup}\n
# time_connect: %{time_connect}\n
# time_appconnect: %{time_appconnect}\n
# time_starttransfer: %{time_starttransfer}\n
# time_total: %{time_total}\n
# http_code: %{http_code}\n
# Alert if time_starttransfer > 0.250 or http_code != 200
What Real User Monitoring Actually Tells You (And What It Doesn't)
Real user monitoring, or RUM, flips the model. Instead of you asking the CDN how it’s doing, your actual users tell you. Every page view, every asset fetch, every navigation timing event gets sampled and sent back. That’s where you discover the truth: the long tail of performance. Synthetic will tell you your p50 TTFB is 45ms. RUM will tell you your p95 in Indonesia on Chrome Mobile is 820ms because of last-mile congestion and cache MISSes that never show up in your lab. If synthetic is your lab test, RUM is the field study — messy, noisy, but honest.
I’ve come to rely on RUM for anything tied to business impact. Core Web Vitals like LCP, INP, and CLS only make sense with real user data — Google's own web.dev Articles make it clear that lab data can't replace field data for these metrics. We once moved image optimization to the edge and synthetic showed a 30ms improvement across the board. Nice, but RUM showed a 1.2-second LCP improvement for users on 3G in Southeast Asia because we’d eliminated a render-blocking origin fetch they were hitting disproportionately. Without RUM, we would have drastically undervalued that change.
The catch with RUM is survivorship bias and noise. You only get data from users who actually loaded the page — you won't hear from users who timed out completely or bounced before your beacon fired. Ad blockers, sampling rates, and browser quirks can also skew your dataset. And RUM can't tell you if a PoP is down *right now* if no users are routed there. You need to sample carefully, beacon efficiently, and be careful not to add overhead to the very experience you’re trying to measure. I typically sample 100% on small sites and 5-10% on high-traffic properties, and I always use `PerformanceObserver` with `buffered: true` so I don't miss early marks.
// Lightweight RUM beacon for CDN asset timing
// Collects Navigation Timing + Resource Timing for CDN host
(function() {
function sendBeacon(data) {
navigator.sendBeacon('/rum/collect', JSON.stringify(data));
}
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.initiatorType === 'img' && entry.name.includes('cdn.example.com')) {
sendBeacon({
type: 'cdn-resource',
url: entry.name,
ttfb: entry.responseStart - entry.requestStart,
download: entry.responseEnd - entry.responseStart,
duration: entry.duration,
cacheHit: entry.transferSize > 0 && entry.encodedBodySize > 0 ?
(entry.transferSize < entry.encodedBodySize) : null,
rttHint: entry.nextHopProtocol
});
}
}
});
observer.observe({ type: 'resource', buffered: true });
// Also capture LCP for page-level CDN impact
new PerformanceObserver((list) => {
const last = list.getEntries().pop();
if (last) sendBeacon({ type: 'lcp', value: last.startTime, element: last.element?.tagName });
}).observe({ type: 'largest-contentful-paint', buffered: true });
})();
The Monitoring Comparison That Actually Helps You Choose
When teams ask me which one to buy first, I tell them they’re asking the wrong question. It’s not synthetic *versus* RUM — it’s about which question you need answered at each stage of the delivery chain. Synthetic tells you “is the CDN reachable and fast under ideal conditions?” RUM tells you “is the CDN fast for the humans who pay us?” You need both to cover the blind spots. My rule of thumb: use synthetic to detect problems, use RUM to understand their impact and prioritize fixes.
For CDN tuning, this distinction gets practical fast. If you're adjusting congestion control or edge buffer sizes, synthetic can validate that your Bandwidth-Delay Product and CDN Tuning: Filling the Pipe Without Overwhelming It calculations hold across your backbone. But only RUM will show you whether that tuning helped users on lossy mobile networks where the product math changes completely. I've seen BDP-tuned edges look perfect in synthetic labs and still retransmit heavily for real users behind carrier-grade NAT.
Budget-wise, start with synthetic if you have to choose. It's cheaper, simpler, and catches the big fires like PoP failures or certificate expirations. Then add RUM as soon as you care about performance as a feature, not just uptime. Here's how I frame the monitoring comparison for engineering leadership when we're planning coverage:
| Signal / Metric | Synthetic Monitoring | Real User Monitoring | Who Wins for CDN Decisions |
|---|---|---|---|
| TTFB (Edge Hit) | 42ms avg, std dev 5ms (controlled, 1-min interval) | 88ms p50, 310ms p95 (global, varies by network) | Synthetic for alerting, RUM for SLOs |
| Largest Contentful Paint | 1.1s on Moto G4 / 3G Fast preset | 2.4s p75 field, 4.8s p95 on 4G Slow | RUM — only field data reflects real UX |
| Cache Hit Ratio / Origin Offload | 99.2% (hitting same 10 URLs) | 91.5% (long tail of unique query strings) | RUM — reveals true cache efficiency |
| Availability / Error Rate | Detects 0.1% edge failure in <60s | Requires 1000s of users to surface same error | Synthetic — fastest detection |
| Geographic Coverage | Limited to 20-50 probe cities | Every user location, including long tail | RUM — catches ISP-specific issues |
Putting It Together: A Practical Implementation That Doesn't Lie to You
Here's where most teams mess it up: they deploy synthetic and RUM in silos and then wonder why they can't correlate anything. You don't need more data, you need joinable data. The trick we've used on high-traffic edge deployments is to inject a consistent correlation vector on every response — a Server-Timing header plus a cdn-trace-id that both your synthetic probes and your RUM beacon can capture. That way, when your RUM shows a p95 LCP spike at 2:14 AM in Jakarta and synthetic looks clean, you can actually tell if it's last-mile congestion or a cache miss storm.
For synthetic, don't overdo frequency. We typically run 1-minute checks from 25-30 well-chosen PoPs — not 150 — covering your top 80% of traffic regions plus two or three canaries in emerging markets. Hit your critical paths: HTML document, one cache-HIT asset, and one cache-MISS/origin path via a bust parameter. For RUM, sample at 100% if you can afford it for the first 24-48 hours after a config push, then dial back to 10-20% once baseline is stable. And please, tag your RUM beacons with cache status (x-cache: HIT/MISS), PoP ID, and ASN. Without that, your CDN Percentile Latency Analysis: What p50, p95, and p99 Actually Tell You is just pretty charts with no root cause.
// Synthetic probe (Node.js) + RUM beacon correlation via Server-Timing
// Run every 60s from your synthetic agents
async function probeCDN(url) {
const traceId = `syn-${Date.now()}-${Math.random().toString(36).slice(2,7)}`;
const res = await fetch(url, {
headers: { 'x-trace-id': traceId, 'cache-bust': `cb=${Date.now()}` },
cache: 'no-store'
});
// Parse Server-Timing: cdn; dur=23, origin; dur=112, cache; desc="HIT"
const serverTiming = res.headers.get('server-timing');
const pop = res.headers.get('x-cdn-pop'); // e.g., SIN-01
const cacheStatus = res.headers.get('x-cache');
reportToCollector({
traceId, pop, cacheStatus, serverTiming,
ttfb: res.timings.ttfb, // measure with perf hooks
status: res.status
});
}
// RUM snippet - grab the same headers via Resource Timing
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.initiatorType === 'navigation') {
navigator.sendBeacon('/rum-collect', JSON.stringify({
traceId: performance.getEntriesByType('navigation')[0].serverTiming?.[0]?.name,
lcp: entry.duration,
cacheStatus: entry.serverTiming?.find(s => s.name === 'cache')?.description
}));
}
}
}).observe({ type: 'navigation', buffered: true });
Once that's wired up, build one dashboard where synthetic and RUM sit side-by-side on the same time axis. Set your alerts so synthetic triggers on availability and baseline deviation — e.g., TTFB > 120ms for 3 consecutive checks from 2+ PoPs — while RUM triggers on percentile regression — e.g., p75 LCP regresses by >15% over a 30-minute rolling window. You'll catch edge config errors with synthetic in under 2 minutes, and you'll catch real user pain that synthetic can't see before your support inbox explodes.
Advanced Patterns: When Your Synthetic and RUM Data Disagree (And They Will)
This is the fun part. If synthetic is green but RUM is red, don't assume your RUM is noisy. Nine times out of ten, it's a last-mile or device issue. We've seen synthetic from a pristine AWS node in Mumbai report 45ms TTFB while RUM p95 on Jio 4G in the same city sits at 1.8s. That's not a CDN failure, that's TCP congestion and carrier middleboxes. Check your RUM breakdown by ASN and connection type first. If the pain is isolated to one or two ASNs and correlates with CDN Jitter Measurement and Its Impact on User Experience spiking above 30-40ms, you're looking at a network path problem, not a PoP problem. Purging cache won't help you.
The inverse — synthetic is red, RUM is green — is rarer but more dangerous. It usually means your synthetic probe is hitting a specific cache key, PoP, or TLS configuration that real users aren't hitting yet. We had a case where synthetic checks using HTTP/2 with TLS 1.3 started failing after a cipher suite rotation, but 92% of RUM traffic was still negotiating HTTP/3 and looked fine. Two days later, as clients rolled over, RUM fell off a cliff. If you see that divergence, immediately compare synthetic request headers (User-Agent, ALPN, ECN) against your top 10 RUM user-agent strings. You're testing a future failure state.
And then there's the subtle throughput killer that neither tool flags cleanly on its own: out-of-order delivery. When synthetic shows stable TTFB but RUM shows high variance in download time for 1MB+ assets with no TTFB correlation, look at edge packet capture. We've debugged cases where Packet Reordering Effects on CDN Performance: The Hidden Throughput Killer caused 20-35% throughput collapse on long-tail mobile clients despite perfect cache hit ratios. Synthetic's single-threaded curl won't see it because it doesn't reassemble like a real handset on a lossy radio. Your fix there isn't more PoPs — it's tuning your origin TCP stack, enabling BBR, or adjusting your CDN's reordering window. You only find that by joining RUM's retransmission hints with synthetic's controlled baseline.
Frequently Asked Questions
Can I just use RUM and skip synthetic if I have enough traffic?
No, and don't try it. RUM is fantastic at telling you *what* users experienced, but it's terrible at telling you *why* or catching issues before users do. Without synthetic's controlled, always-on baseline from known vantage points, you lose the ability to detect a full PoP outage in a low-traffic region at 3 AM or validate a config before it hits 1% of real users. You need synthetic for proactive alerting and pre-production validation.
How do I avoid RUM data skewing my performance metrics?
Two things: sample correctly and segment ruthlessly. Never look at global averages. Always segment by cache status, PoP, device class, and effective connection type (4G vs. 3G). A 200ms improvement in cache HIT ratio can hide a 600ms regression for cache MISS users if you only watch the blended average. And filter out bots and headless traffic — they can inflate your p50 by 15-25% if you're not careful. We enforce a minimum sample of 500 beacons per segment per 5-minute window before we trust a percentile.
What's the ideal synthetic check frequency? Won't 1-minute checks cost a fortune?
It's cheaper than an outage. 30-second checks are overkill for most sites and you'll get rate-limited or flagged as abuse. 1-minute from 25-30 PoPs on 3-5 URLs is the sweet spot — that's about 130k checks per day, which on most platforms like Catchpoint or ThousandEyes runs well under $1k/month. For cache MISS/origin paths, drop to every 5 minutes. You're not looking for second-level blips there, you're looking for origin reachability and TLS validity.
Where should I inject the RUM beacon for CDN analysis?
Don't use a third-party RUM script hosted off your CDN if you can avoid it — you're adding a dependency you're trying to measure. Self-host your 2-5KB beacon JS on the CDN itself with a long TTL and serve it with Timing-Allow-Origin: *. Collect beacons via POST to an edge worker that enriches with PoP and ASN data server-side, so you don't rely on client-side geo. That gives you sub-50ms beacon overhead and 99.9% collection success, even on flaky networks.