Skip to content
GDPR compliant. Hosted in DE.
14-day free trial →
Uptime monitoringCachingCDN
Monitoring 14 min read 16 Sept 2026

Origin Behind the CDN: When Cache Hides an Outage

An uptime check can report green while the origin server has long been down: the edge answers from cache. How Age and Cache-Status expose the blind flight and how two separate check paths reveal the outage before visitors do.

X

XICTRAQ

XICTRAQ

MonitoringCDNCachingAvailability
35 %
of mobile HTML requests run via a CDN (Web Almanac 2025)
0.2 %
of desktop requests set stale-if-error (Web Almanac 2021)
86,400 s
default for CacheMaxExpire in mod_cache (Apache project)
Cache hides the outageThree layers, one signal: what the check sees and what it missesVisitorsees a page that loadsRequestCDN edgeanswers from the store, Age climbs200 from cacheno fetch neededOrigin serverreturns 503 but is not askedno answerAge of the responsemax-age 600 s, stale-if-error 1200 sAge 9000 s600 s1800 sEdge keeps servingOrigin has stopped answeringAfter 1800 s the store runs out1800seconds of blind window35 %of HTML requests are served via a CDN(Web Almanac 2025, mobile requests)0.2 %of requests set stale-if-error(Web Almanac 2021, desktop)Sources: RFC 5861, RFC 9111, RFC 9211, Web Almanac
The uptime check reports 200, the status page is green, and a customer still calls: orders are not going through. Both observations are correct at the same time. The check received a response, just not from the origin server but from a cache that is serving an older copy. This is not a defect, it is explicitly foreseen: a cache may only generate a stale response when it is disconnected from the origin or when the client or the origin server has permitted it - and not even then if a directive such as no-cache or must-revalidate prohibits it (RFC 9111). How long that window stays open is a matter of configuration - the default upper limit for how long a document is cached in mod_cache is 86,400 seconds, one day (Apache project). And a CDN now sits between visitor and origin for 35 percent of mobile HTML requests (Web Almanac 2025). Anyone checking a single address is checking the cache in these cases.

What the green check mark actually proves

An HTTP check proves exactly one thing: someone answered at the address under test with a status code. Who sent that response is not part of the status code. If a CDN sits in front, the check typically terminates at the nearest edge, and the edge has two options - ask the origin or answer from its own store. The second path is faster and therefore the usual one. A 200 consequently says nothing about whether the application behind it is still running, whether the database is reachable, or whether the checkout completes.

This is not a malfunction of the cache, it is its job. RFC 9111 describes a stale response as one that carries expiration information or may be calculated heuristically, but is no longer fresh after the freshness calculation. A cache may generate it anyway, but only under one of two conditions: it is disconnected from the origin, or the client or the origin server explicitly permitted it, for instance through the extensions in RFC 5861. Where a directive such as no-cache, must-revalidate, s-maxage or proxy-revalidate prohibits the stale response, it stays forbidden in those situations too (RFC 9111). So the edge does what it was told to do - it keeps the visitor away from an error page. But in doing so it also keeps the monitoring away from the situation.

  • What the check proves: the address is reachable, TLS is up, a status code came back, the response time was within range.
  • What the check leaves open: whether the origin server was involved in that response and how old the delivered content is.
  • What follows from that: an origin outage stays invisible for as long as the edge has supplies - with an unfavourable setting, for hours.
  • What that means for detection time: the chosen check interval shortens nothing here, because every single check receives the same cached response.

Age: the header that reveals the origin

There is one signal available without any extra effort that answers half the question: the Age header. RFC 9111 states its evidential value in two sentences that have to be read separately. The first: the presence of an Age header field implies that the response was not generated or validated by the origin server for this request (RFC 9111). The second limits the inverse: a lack of an Age header field does not imply the origin was contacted (RFC 9111). An Age value is therefore solid proof of a cache response, but its absence is no proof of the opposite.

In practice this means: an Age value that grows steadily across several checks while nothing else changes is the signature of a frozen copy. As long as the origin responds, the value drops back to a small number after each refresh. If instead it keeps climbing past the freshness lifetime, the edge is serving from its store - and that is the point at which monitoring that only reads the status line stops knowing anything. The value alone is not yet an alert, but it is the only figure in the standard set of response headers that points in this direction at all.

Two sentences, two different conclusions

The Age header carries its statement in one direction only: it proves a cache response, its absence proves no origin response (RFC 9111). Anyone building a check rule on it should therefore phrase it as evidence and not as an exclusion. The same pattern applies to intermediate proxies when determining the real visitor IP: a header that is set is information, a header that is missing is not.

How long the blind window is

The duration of the blindness can be calculated as soon as two values are known: the freshness lifetime and the grace period for error cases. RFC 5861 introduces stale-if-error for this and defines the notion of an error narrowly - it means any situation that would result in a 500, 502, 503, or 504 status code being returned (RFC 5861, Informational). The example in the document is clear enough to be adopted directly: a response with max-age=600 and stale-if-error=1200 is fresh for 600 seconds and may then be used for another 1200 seconds in the error case. Once the age exceeds 1800 seconds, the cache must write the error message through (RFC 5861, Informational).

SettingEffect on the blind flightDocumented defaultSource
max-agehow long the copy counts as freshnone, comes from the responseRFC 9111
stale-if-errorgrace period during which the old copy is used on 5xxnone, comes from the responseRFC 5861 (Informational)
stale-while-revalidateold copy immediately, refresh in the backgroundnone, comes from the responseRFC 5861 (Informational)
Heuristic freshnessfreshness estimated without any directivetypically 10 percent since the last modificationRFC 9111
CacheMaxExpireupper limit for serving without asking back86,400 seconds, one dayApache project
CacheDefaultExpirelifetime without expiry and modification date3,600 seconds, one hourApache project
CacheLastModifiedFactorfactor for the estimated freshness0.1Apache project
CacheStaleOnErrorold copy instead of a 5xx responseonApache project
proxy_cache_use_staleold copy on error or timeoutoffnginx
proxy_cache_background_updatebackground refresh while a stale copy is servedoffnginx

Two rows of this table deserve particular attention. First the heuristic: if a response carries neither an expiry date nor Cache-Control, a cache may estimate the freshness lifetime, and RFC 9111 names ten percent of the time since the last modification as a typical setting (RFC 9111). For a file last modified a year ago that is a good five weeks. Second the defaults: in mod_cache, serving stale content in place of 5xx responses is on by default (Apache project), whereas in nginx proxy_cache_use_stale ships as off (nginx). The same application therefore behaves fundamentally differently behind two intermediary layers - and both behaviours are documented, neither of them is a bug.

The supply is the real detection time

As long as the edge can deliver, it pushes the visible outage backwards. With max-age 600 and stale-if-error 1200 that is 1800 seconds in which a dead origin looks like a healthy one (RFC 5861, Informational). Whoever knows that span can monitor it; whoever does not ends up measuring the stamina of the cache and calling it availability. How the same delay shows up after a release is covered in stale cache after deploy.

Cache-Status: the answer to the actual question

Since RFC 9211 there has been a standardised header that reports exactly what monitoring wants to know. The Cache-Status field carries a list in which each member represents a cache that has handled the request; the first member represents the cache closest to the origin server, and the last member represents the cache closest to the user (RFC 9211). This makes it possible to read how many layers were involved and what each of them did. For the case at hand, one sentence from the document is decisive: a stale response that is used without going forward - for example because the origin server is not available - can be considered a hit (RFC 9211).

edge-response.http
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Cache-Control: max-age=600, stale-if-error=1200
Age: 1490
Cache-Status: upstream-cache; fwd=stale; fwd-status=503, edge; hit; ttl=-890

Three pieces of information in this response contradict the green check mark. Age stands at 1490 seconds and is therefore far beyond the 600 seconds of freshness lifetime. The ttl parameter states the remaining freshness and may be negative to indicate staleness (RFC 9211) - here 890 seconds are missing. And fwd=stale states that the cache was able to select a response for the request, but that it was stale (RFC 9211), while fwd-status carries the status code the next-hop server returned (RFC 9211). So the origin answered 503 and the visitor received 200. Both are in the same response, and a check that only reads the status line sees none of it.

How widespread the situation is

The mechanism is not a special case of individual setups. In the Web Almanac 2025, 35 percent of mobile HTML requests are answered via a CDN, subdomain resources reach 52 percent and third-party content 71 percent (Web Almanac 2025). For HTML the share rose from 33 to 35 percent between 2024 and 2025 (Web Almanac 2025) - so the proportion of sites with a layer between check and application is tending to grow. Anyone planning to use a CDN is planning that layer into their own monitoring as well.

62.2 percent carry max-age

For 62.2 percent of desktop requests the response carries a max-age directive (Web Almanac 2021). The freshness lifetime is therefore set in the majority of cases and the blind span is in principle calculable.

0.2 percent set stale-if-error

The grace period for error cases is rarely set explicitly: for 0.2 percent of desktop requests the response carries stale-if-error (Web Almanac 2021). Where it is missing, the default of the intermediary layer decides on its own.

2.3 percent use stale-while-revalidate

At 2.3 percent of desktop requests, background refreshing also remains an exception (Web Almanac 2021), even though it closes precisely the gap between a fast response and a current one.

24.6 percent without any directive

For 74.8 percent of desktop requests the response uses Cache-Control, for 24.6 percent neither Cache-Control nor Expires (Web Almanac 2021). In that quarter the heuristic decides the freshness lifetime.

The distribution explains why the problem so often goes unnoticed. Explicit instructions for the error case are the exception, the defaults of the intermediary layers are the rule - and those already differ, as the table shows, between two widely used servers. For monitoring, an uncomfortable consequence follows: the blind span of a website cannot be derived from the application, it can only be measured on the delivered response.

Two check paths instead of one

The resolution is unspectacular and costs little: check both ends of the chain separately and compare the results. The public path measures what the visitor receives. The second path goes past the cache and measures whether the application responds. Together the two results carry a statement that neither of them can carry alone.

  • Public path: the regular address via the edge, with assertions on status code, content and response time.
  • Origin path: the same application addressed directly, either through a dedicated hostname or through a request with a Host header set against the origin address.
  • Health endpoint: a lean address that touches the database and the most important dependencies and is explicitly delivered as non-storable.
  • Comparison: if the origin path deviates from the public path, that is the finding - and it arrives before the edge has used up its supply.
$ curl -sSI https://www.example.invalid/ | grep -Ei '^(age|cache-control|cache-status):'
age: 1490 cache-control: max-age=600, stale-if-error=1200 cache-status: upstream-cache; fwd=stale; fwd-status=503, edge; hit; ttl=-890
$ curl -sS -o /dev/null -w '%{http_code}\n' --resolve www.example.invalid:443:203.0.113.10 https://www.example.invalid/health
503
$ curl -sSI https://www.example.invalid/health | grep -i '^cache-control:'
cache-control: no-store

The first call answers the question of provenance without anything having to be installed. The second bypasses the edge through a fixed name resolution and hits the origin directly - here the 503 appears that the visitor does not get to see. The third checks whether the health endpoint is itself storable; if it is, that path too will eventually measure nothing but a copy of itself. Anyone checking from several locations additionally recognises whether only individual edges are affected - which is the subject of the article on monitoring from multiple locations.

Assertions that measure the difference

Two paths only turn into monitoring once rules are attached to them. What helps are assertions that do not repeat the status code but evaluate the provenance and the age of the response. Three rules cover the majority of cases: an upper bound for Age on the public path, an exclusion of fwd=stale across several consecutive checks, and a hard status comparison between the public path and the origin path.

cache-state.py
from dataclasses import dataclass

ERROR_STATUS = {500, 502, 503, 504}   # notion of an error from RFC 5861

@dataclass
class Response:
    status: int
    age: int | None             # None means: the header was not set
    max_age: int | None
    stale_if_error: int | None
    fwd: str | None             # value from Cache-Status, if present

def blind_window(r: Response):
    """Seconds during which a dead origin can stay hidden."""
    if r.max_age is None:
        return None                     # unmeasured, not 0
    return r.max_age + (r.stale_if_error or 0)

def state(r: Response) -> str:
    if r.status in ERROR_STATUS:
        return "error written through"
    if r.fwd == "stale":
        return "origin not responding"
    if r.age is None:
        return "undetermined"           # no evidence in either direction
    if r.max_age is not None and r.age > r.max_age:
        return "stale copy"
    return "fresh"

def remaining(r: Response):
    window = blind_window(r)
    if window is None or r.age is None:
        return None
    return max(window - r.age, 0)

The functions deliberately return None when the Age header is missing or max-age is not set. A missing header is not a zero value, it is a measurement that did not happen - and monitoring that treats the two alike reports calm where it measured nothing. The same separation pays off for response times, where a single outlier disappears in the average and only becomes visible in the p95 and p99 percentiles.

Alerting before the supply runs out

The timing of the alert decides whether the monitoring is still useful. If the alert only fires once the edge has no copy left, the outage is already visible to everyone - at that point the monitoring reports the same thing as the telephone. The gain lies in the span before that: it begins the moment the origin path turns, and it ends as soon as the age of the delivered response exceeds the sum of freshness lifetime and grace period (RFC 5861, Informational).

  • Stage 1, immediately: the origin path answers with 500, 502, 503 or 504 (RFC 5861, Informational) while the public path still answers 200. This is the silent phase and the earliest sensible moment for a notification.
  • Stage 2, after confirmation: fwd=stale appears on the public path or ttl turns negative (RFC 9211). The edge is now demonstrably serving from its store.
  • Stage 3, before the end: Age approaches the sum of max-age and stale-if-error. From here the outage is foreseeable for visitors and no longer only for the monitoring.
  • Standing rule: the health endpoint stays uncached, otherwise stage 1 loses its basis. How to separate error responses cleanly from outliers is covered in the article on the 5xx server error rate.

A green check mark without a statement about the provenance of the response is a statement about the cache, not about the website. The difference costs almost nothing to set up and decides whether a notification arrives in time.

— XICTRAQ

What can be set up this week

The effort stays manageable because all the required information is already in the response headers. One pass: retrieve the Age, Cache-Control and Cache-Status headers on a sample page and note the blind span. Then set up the origin as a separate check path, either through an internal hostname or through a fixed name resolution. Finally, add an assertion on the Age value and set the health endpoint to no-store. A complete inventory of the checked addresses shows which entry points have no check path at all so far.

Ahead of traffic peaks the pass is worth twice as much, because the hit rate at the edge and the load at the origin rise at the same time - what to watch out for is covered in preparing for peak season. In XICTRAQ both paths can be run as separate checks, header assertions belong to API monitoring, and beyond the configured alert channels the notification also reaches the status page so that customers see the same situation as the team. Anyone who wants to look at the interplay first will find it in the demo view; the basics of availability checking are described under uptime monitoring.

Sources and studies

This article draws on: RFC 9111 (HTTP Caching, sections 4.2.2, 4.2.4 and 5.1), RFC 5861 (Informational; stale-if-error and stale-while-revalidate), RFC 9211 (the Cache-Status header), the documentation of the mod_cache module in the Apache project and the documentation of ngx_http_proxy_module in nginx. The prevalence figures come from the Caching chapter of the Web Almanac 2021 and the CDN chapter of the Web Almanac 2025.

Why does my uptime check report green even though the server is down?
As a rule because the check terminates at an intermediary layer and a stored copy answers there. A cache may only generate a stale response when it is disconnected from the origin or when it has been permitted to do so - and not where a directive such as no-cache or must-revalidate prohibits it (RFC 9111). The status code 200 then refers to the edge and not to the application behind it. A look at the Age header typically reveals this straight away.
How long can an outage behind a CDN stay unnoticed?
That depends on the sum of freshness lifetime and grace period. In the example from RFC 5861 a response with max-age=600 and stale-if-error=1200 is usable for a total of 1800 seconds, after which the cache writes the error message through (RFC 5861, Informational). Where no explicit directive exists, the default of the intermediary layer decides - in mod_cache the upper limit is 86,400 seconds (Apache project). In our experience real windows range from a few minutes to several hours.
Does a missing Age header prove that the response came from the origin?
No. RFC 9111 explicitly records that a lack of an Age header field does not imply the origin was contacted (RFC 9111). The header works as evidence for a cache response, but not as an exclusion. For a solid statement you need a second check path that bypasses the intermediary layer, or the Cache-Status header.
What does the Cache-Status header say that Age does not?
It names the layers involved individually and shows what each of them did. The first member of the list represents the cache closest to the origin server, and the last member the cache closest to the user (RFC 9211). The value fwd=stale indicates that a response could be selected but that it was stale (RFC 9211), and fwd-status carries the status code the next-hop server returned (RFC 9211). This puts the origin's 503 into the same response that appears to the visitor as a 200.
Is it enough to shorten the check interval?
Not in this case. As long as every check receives the same cached response, a shorter cadence changes nothing about the result - it merely raises the hit rate at the edge. What works is a second check path that addresses the origin directly, together with an assertion on Age or Cache-Status. For other questions the interval remains important, for instance for the detection time of a complete outage.
How do I stop my health endpoint from being cached itself?
By delivering the response explicitly as non-storable, usually via Cache-Control: no-store. Without such a directive a cache may estimate the freshness lifetime heuristically; RFC 9111 names ten percent of the time since the last modification as a typical setting (RFC 9111). For rarely changed files that produces surprisingly long windows. It also helps to place the endpoint on a path that is excluded from the intermediary layer.
Start in 60 seconds

Let XICTRAQ watch your website.

Permanent free plan available. No credit card, no installation, no hidden fees.