A few years back, I was on call when our status page showed 100% uptime while our largest video customer was screaming about buffering failures in Brazil. Our synthetic checks from Virginia were all green, but their users weren't getting segments. That gap between what we measured and what users felt is exactly why availability calculation for CDNs is so tricky — you can't just count pings and call it a day.
The Textbook Formula Falls Apart at the Edge
On paper, availability looks simple: successful requests divided by total requests, multiplied by 100. I've seen junior teams plug that into a Grafana dashboard, ship it, and feel confident. The problem is that formula hides every decision that actually matters for a globally distributed CDN. What counts as a "successful" request? Is a 10-second TTFB success if the content eventually arrives? Does a 206 Partial Content count the same as a 200? If you don't define that up front, your 99.95% doesn't mean much.
Early in my career I worked on a CDN that counted any HTTP response — even a 5xx served from origin — as "available" because the edge itself hadn't crashed. Technically the server was up, but from the customer's perspective, their site was down. We've since learned to define availability from the requester's point of view. A request is only successful if it returns the expected content within an agreed latency budget and with a non-error status. That shift in definition changed our reported availability by nearly half a percent, which sounds small until you translate it into error budget.
If you're aligning with broader reliability goals, you have to tie this definition to your SRE Practices for CDN: Applying Site Reliability Engineering to Content Delivery approach before you write a single query. Otherwise you'll be measuring the wrong thing beautifully. Here's the core calculation I use now, which explicitly filters for edge-served success and excludes origin-induced errors that shouldn't count against the CDN's SLI:
# Availability SLI for CDN Edge (Python-like pseudocode)
# Successful = 2xx/3xx from edge cache + revalidated 304s within SLO latency
# Total = All user-initiated requests (excludes bot health checks)
def calculate_availability(requests):
successful = 0
for req in requests:
is_success_status = 200 <= req.status < 400
is_within_slo = req.ttfb_ms <= 1000 and req.download_time_ms <= 3000
is_valid_edge_response = req.served_by == "edge" and not req.is_origin_5xx
if is_success_status and is_within_slo and is_valid_edge_response:
successful += 1
total = len([r for r in requests if not r.is_synthetic_heartbeat])
return (successful / total) * 100 if total > 0 else 100.0
# Example: 9,985 good / 10,000 valid = 99.85% availability
# Downtime = (1 - 0.9985) * 43,200 mins/month = ~64.8 minutes
Synthetic Probes vs. Real User Monitoring: You Need Both
There's a constant debate on our team: should we trust synthetic checks or Real User Monitoring (RUM) for uptime measurement? My honest answer after ten years is that either one alone will lie to you. Synthetics are fantastic for controlled, consistent measurement. I run probes every 30 seconds from 40+ PoPs that fetch a known byte-range from an anycast endpoint. They tell me immediately if a PoP is isolated or if our TLS handshake is failing. But synthetics can't see that Telco X in Jakarta is throttling us, or that iOS users on HTTP/3 are hitting a different failure path.
RUM flips that. It gives you ground truth — what actual end-users experienced — but it's noisy and biased. High-traffic regions dominate your numbers, ad-blockers drop your beacons, and users who can't load your JS don't get counted at all. I remember we had a configuration bug that broke our RUM collector itself; our availability metric looked *better* because the failures weren't reporting. That's why the HTTP Working Group Specifications (https://httpwg.org/specs/) are useful reading — they help you define what a successful exchange should look like at the protocol level, so your RUM and synthetics share the same definition of success for status codes, caching, and retries.
The methodology that finally worked for us was treating synthetic availability as our internal SLO and RUM availability as our user-facing SLI. We alert on synthetics because they're fast, but we report and burn CDN Error Budget Management: Balancing Reliability with Feature Velocity on RUM. When they diverge by more than 0.05% over a rolling hour, that's a signal we've got a blind spot. Here's a PromQL-style query we've adapted to measure probe-based availability without letting a single flapping PoP skew the global number:
# Probe-based Edge Availability (Prometheus / Thanos)
# Counts only probes that received valid HTTP response within SLO
# Uses 5m window to avoid flapping
(
sum by (pop) (
increase(cdn_probe_result{status=~"2..|3..", ttfb_ms<="1000"}[5m])
)
/
sum by (pop) (
increase(cdn_probe_requests_total{job="synthetic-edge-check"}[5m])
)
) * 100
# Global availability: weighted by regional traffic, not probe count
# Prevents a quiet PoP with 99% probes from hiding issues in a major market
global_availability = sum(probe_availability * region_traffic_weight) / sum(region_traffic_weight)
Defining Windows, Exclusions, and What Counts as Downtime
Once you've got your data sources, the next argument is always about time windows. Don't make the rookie mistake of using calendar-month availability. It makes month-to-month comparisons meaningless and punishes you unevenly in February. We've standardized on 30-day rolling windows and 5-minute evaluation buckets. Short enough to trigger a fast incident response, long enough that a single packet loss doesn't crater your SLO. The web.dev Articles (https://web.dev/articles/) do a good job explaining how user-perceived latency thresholds affect availability, which helped us justify moving our TTFB cutoff from 2 seconds to 1 second for cache HITs.
You also have to be ruthless about documenting exclusions ahead of time, not after an outage. We exclude scheduled maintenance with 72-hour notice, customer-triggered origin 5xx, and requests blocked by WAF rules the customer wrote themselves. Everything else counts. I push teams to write this down in their SLO doc and get customer sign-off, because I've been in those postmortems where everyone's arguing if a 30-minute DNS misconfiguration "shouldn't count." If it wasn't agreed before, it counts.
Automating that classification is where most teams save themselves hours of toil. Manual spreadsheet reconciliation after an incident doesn't scale when you're doing millions of requests per second. We invested early in tagging every request at the edge with `served_by`, `cache_status`, and `origin_status`, so our pipeline can classify failures automatically. If you haven't yet, check your approach against CDN Toil Reduction Through Automation: Eliminating Repetitive Operations Work — we've cut our monthly SLI calculation time from two days to twenty minutes by doing this. The table below is the decision matrix we circulate to engineers so there's no ambiguity when we're calculating downtime.
| Availability Target | Allowed Downtime / 30 Days | Measurement Source | Counts as Failure? |
|---|---|---|---|
| 99.9% (three nines) | 43m 12s | RUM (user-facing) + synthetics | Edge 5xx, TTFB >1s on HIT, timeout |
| 99.95% (common CDN SLA) | 21m 36s | Weighted RUM, 5-min buckets | Same as above, excludes customer origin 5xx |
| 99.99% (four nines) | 4m 19s | Synthetics (alerting), RUM (reporting) | Any non-2xx/3xx or stale content > SLO |
| 99.0% (best-effort) | 7h 18m | Synthetics only | Edge unreachable, ignores latency |
Putting It Into Practice: Building an Availability Pipeline That Doesn't Lie
Once you've nailed down your definitions, the next challenge is actually computing availability without introducing new biases. We've seen teams pull raw logs from 150+ PoPs into ClickHouse or BigQuery and then slap a simple successful_requests / total_requests query on top. That'll give you a number, sure, but it won't survive an audit. You need to normalize by traffic volume and by region, or a single high-traffic PoP in Ashburn can mask a complete outage in Johannesburg.
What works better is a hierarchical aggregation. First, calculate availability per PoP per minute using your validated success/failure classification. Then weight each PoP's contribution by its share of legitimate user traffic - not by request count alone, because 10,000 1KB health checks shouldn't outweigh 1,000 5MB video segment deliveries. We typically apply a 1-minute bucket with a minimum sample threshold; if a PoP saw fewer than 60 requests in that minute, we don't mark it down for availability - we mark it as "insufficient data" and fall back to synthetic probe results for that window. Without that, you'll flag every low-traffic edge as flapping at 3 AM.
Don't forget to version your logic. Your availability calculation is code, and it needs the same rigor as any other production service. I've seen availability jump from 99.92% to 99.97% overnight because someone tweaked the timeout from 5 seconds to 10 seconds and didn't document it. That's not an improvement, it's a measurement artifact. That kind of silent drift is exactly what leads to The CDN Automation Reliability Paradox: When Automation Causes More Outages - when your automation optimizes for a metric that's quietly changed underneath it.
# Weighted Availability Calculation (Python / PySpark style)
# Calculates per-PoP availability, then weights by traffic share
def calculate_weighted_availability(minute_buckets):
total_weighted_uptime = 0
total_weight = 0
for bucket in minute_buckets:
if bucket.request_count < 60:
# Fall back to synthetic for low-traffic minutes
is_available = bucket.synthetic_success_rate >= 0.99
weight = bucket.synthetic_probe_count
else:
# RUM-based: success = 2xx, 3xx, 416 + origin 4xx exclusion
failures = bucket.count_5xx + bucket.timeout_count
is_available = (failures / bucket.request_count) < 0.001 # <0.1% error budget
weight = bucket.bytes_served # Weight by bytes, not just requests
# Exclude contractually agreed maintenance windows
if bucket.is_excluded_window:
continue
total_weighted_uptime += (1.0 if is_available else 0.0) * weight
total_weight += weight
return (total_weighted_uptime / total_weight) * 100 if total_weight > 0 else 100.0
# Example: 43,200 minutes in a 30-day month
# 99.95% = 21.6 minutes of weighted downtime allowed
Advanced Patterns: Debugging When Your Numbers Don't Match Reality
Even with a solid pipeline, you'll hit discrepancies that'll make you question everything. The most common one we troubleshoot: your synthetic probes report 99.99% while RUM shows 99.82%. That's not a bug, it's a signal. Usually it means your synthetic checks are hitting cached /healthcheck.html files that never touch origin, while real users are getting 5xx on uncacheable API calls that collapse under load. If your synthetic suite hasn't been updated since you launched that new GraphQL edge function, you're measuring yesterday's CDN, not today's.
Another pattern that trips up experienced teams is partial PoP degradation. Let's say Frankfurt is serving 50% of requests in 250ms and 50% in 8 seconds due to a saturated NIC. Is that "available"? Technically yes, if you only count 5xx. For users, it's a hard outage. That's why we layer in a latency SLO alongside availability - e.g., 95th percentile TTFB must be under 800ms. If you breach latency SLO for more than 3 consecutive minutes, we count it as unavailable for that PoP. You’ll want to codify this in your incident handling, which is where CDN Runbook Automation: Converting Manual Procedures into Executable Scripts becomes critical - you can't manually decide during a 2 AM page whether slow is down.
Finally, watch out for clock skew and log delay. We've debugged "outages" that were just 45-second ingest lags from a PoP in Singapore. Always align on UTC, add a 5-minute watermark delay before finalizing availability buckets, and keep a separate real-time "provisional" view for NOC dashboards. And when you do have a real miss, don't just patch the calculation - run a proper CDN Postmortem Analysis: Learning from Incidents to Prevent Recurrence. More than half the time, the root cause isn't the CDN itself, it's how we chose to define and measure success in the first place.
Frequently Asked Questions
Should we include origin 5xx errors in CDN availability?
No - if you've correctly classified them. A 502 or 503 returned because your origin is down isn't a CDN failure, it's an origin failure the CDN is faithfully reporting. We exclude origin-attributable 5xx (where the CDN successfully connected and proxied the error) but we *do* include 5xx generated at the edge - like 502s from a PoP that can't reach origin due to its own network partition, or 503s from edge compute timeouts. The key is tagging the error source at the edge, not just looking at the status code in the access log.
What's a good SLO target for a global CDN - 99.9%, 99.95%, or 99.99%?
Don't pick 99.99% because it sounds good in a sales deck. For a multi-PoP CDN serving dynamic content, 99.95% (21m 36s downtime per month) is aggressive but achievable if you have solid failover. 99.99% (4m 23s per month) requires active-active PoPs, Anycast with sub-30-second BGP convergence, and origin shielding that can survive regional failures - most teams underestimate the cost by 3x. Start at 99.9% (43m 49s) with separate 99.95% targets for cached/static traffic, then tighten it once your measurement pipeline has 90 days of stable data.
How do you handle scheduled maintenance windows in availability calculations?
You exclude them - but only if they're pre-declared, customer-acknowledged, and don't exceed your error budget. We require a 72-hour notice, a maintenance window tagged in our config management, and we cap exclusions at 4 hours per month. If you let any "planned" purge or cert rotation exclude downtime, you'll hide 30-40% of your actual user impact. Pro tip: track two numbers - contractual availability (with exclusions) and operational availability (without). Your customers care about the first, your engineers should be judged on the second.
Why does our billing-report availability differ from our monitoring dashboard?
Almost always due to aggregation windows and success definitions. Billing often uses 5-minute buckets and counts any 2xx/3xx as success, while your NOC dashboard uses 1-minute buckets with stricter timeout thresholds (like 5s vs. 10s). A 90-second blip that shows as 98% in a 1-minute view gets smoothed to 99.6% in a 5-minute rollup. Reconcile them by locking a single source of truth - we use 1-minute weighted buckets for both, and generate billing reports by re-aggregating those same buckets. If you maintain two separate pipelines, they'll diverge by 0.05-0.15% every month, guaranteed.