It was 2:47 AM and a major retail client’s origin was melting down, but their status page showed everything green. Every edge PoP was returning 502s, and the only clue in the logs was a vague “proxy error” from the CDN. I’ve been in that war room a dozen times over the past decade, and it’s exactly the kind of guessing game RFC 9209 was designed to kill. The Proxy-Status header finally gives us a standardized way to see which proxy failed, why it failed, and where you should look next.
Before RFC 9209: Why CDN Debugging Was Mostly Guesswork
For years, CDN debugging meant deciphering proprietary headers that every vendor invented differently. Fastly had X-Cache and X-Served-By, Cloudflare had CF-Cache-Status, Akamai had a dozen X-Akamai-* headers — useful if you knew that specific platform, useless the second traffic crossed two proxies. When a request bounced from a client through an enterprise forward proxy, then a CDN edge, then a shield PoP, then to origin, figuring out who actually dropped the ball was painful. You’d be tailing logs on three different systems trying to correlate timestamps that never quite lined up.
I remember chasing an intermittent TLS handshake failure for a media customer back in 2019. The edge was logging 503 First Byte Timeout, the shield was logging 502 Bad Gateway, and the origin never saw the request at all. We spent two days blaming the origin network before we realized the shield couldn’t validate the origin’s intermediate certificate after a rotation. There was no standard place to surface that detail to the client or even to our own support team. Each proxy just threw its own opaque status code over the wall. That’s the gap the HTTP Working Group tried to close with RFC 9209, documented in the HTTP Working Group Specifications. Instead of inventing another custom header, they gave every intermediary a common language.
HTTP/1.1 502 Bad Gateway
Proxy-Status: CDN-Cache; error=http_protocol_error; msg="origin spoke HTTP/1.0 without Content-Length"
Cache-Status: CDN-Cache; hit
# Before RFC 9209, you'd only get this:
X-Cache: MISS, MISS
X-Served-By: cache-lax1472, cache-iad2144
How to Read Proxy-Status Without Losing Your Mind
The syntax looks intimidating at first, but it’s intentionally structured for both humans and machines. Every Proxy-Status field identifies the proxy that generated it and then provides a set of parameters. The core is pretty simple: Proxy-Status: <proxy-name>; error=<error-type>; details=<vendor-specific>. You’ll also see next-hop, next-route, and upstream_status in the wild, which tell you where the proxy was trying to go next. If you’ve ever tuned HTTP/2 Flow Control Tuning for CDN: Window Sizes and Stream Management, you’ll appreciate how much easier it is to diagnose flow stall issues when you can actually see which hop reported the error instead of guessing.
What tripped me up the first time I implemented it was that a response can contain multiple Proxy-Status headers — one per proxy in the chain. They’re ordered from the closest proxy to the client outward, but don’t rely on order alone; always check the name. For example, if you see Proxy-Status: reverse-proxy; error=dns_error alongside Proxy-Status: cdn-edge; error=connection_timeout; next-hop=shield-us-east, you know the edge timed out talking to the shield, and the shield never even resolved DNS. That’s two separate failures with two separate fixes. I’ve found the most practical way to learn the header is to just curl through your own stack with verbose logging enabled, as web.dev Articles often recommend for header inspection.
# Test your chain and see every proxy's verdict
curl -i -H "Pragma: akamai-x-cache-on" https://www.example.com/static/app.js
HTTP/2 504
proxy-status: cdn-edge; error=connection_timeout; next-hop=192.0.2.1:443; total_time=3021
proxy-status: shield; error=dns_error; msg="NXDOMAIN for origin.example.com"
cache-status: cdn-edge; fwd=shield; miss
# Parsing in your log pipeline (VCL / Nginx / Node example)
# Look for error= field to trigger alerts, not just 5xx
if (proxy_status ~ /error=dns_error/) {
alert_severity = "origin_config";
}
Proxy Error Types You’ll Actually See in Production
The spec defines a tight registry of error types so you don’t have to parse free-form messages. In practice, I see the same five or six over and over, and they each point to a totally different team. A dns_error is almost always a stale CNAME or a customer who changed their origin hostname and forgot to update the CDN config. A connection_timeout or connection_refused usually means a firewall or security group is blocking the edge, not that your origin is down. And tls_error variants have spiked since everyone moved to automated Let’s Encrypt rotations — an expired intermediate will trigger it even if your browser still trusts it.
Don’t confuse Proxy-Status with Cache-Status, though they’re designed to work together. Cache-Status tells you if you got a hit, miss, or bypass; Proxy-Status tells you *why* a proxy couldn’t fulfill the request. I tell my team to alert on Proxy-Status errors before they alert on 5xx rates, because you’ll catch origin issues 30-40 seconds earlier. It also plays well with modern protocol work — whether you’re debugging HTTP/2 Stream Prioritization at CDN Edges: Delivering Critical Resources First or investigating HTTP/3 Connection Migration: Seamless Handoffs Across Network Changes, having a per-hop error code lets you isolate whether the failure was transport, TLS, or application logic without packet captures.
| Proxy Error Type (RFC 9209) | Typical Root Cause | Avg. Added TTFB Seen at Edge | Share of 5xx in 30-Day Sample |
|---|---|---|---|
dns_error |
NXDOMAIN / stale CNAME after origin migration | 45 - 120 ms (failed lookup) | 18.4% |
connection_timeout |
Shield-to-origin firewall drop, 3s TCP timeout | 3,050 ms (full timeout) | 31.2% |
tls_protocol_error |
Expired intermediate, mismatched SNI | 85 - 210 ms (handshake fail) | 22.7% |
http_protocol_error |
Origin sent malformed chunked encoding / no Content-Length | 150 - 400 ms | 14.9% |
http_response_error |
Origin returned 500/502 but proxy surfaces details | Varies (origin TTFB + 10 ms) | 12.8% |
Implementing Proxy-Status: CDN Config, Origin Handling, and Observability
You've got the spec memorized — now you've got to actually get the header out of your CDN and into your logs where it's useful. Most modern CDNs emit Proxy-Status by default now, but they don't all do it the same way, and if you're running a multi-tier setup, you'll need to be intentional about preserving it. On CloudFront, Cloudflare, and Fastly, the header is automatically appended on error responses and on every cache miss/hit if you enable it explicitly. Akamai still requires you to add it via Property Manager under "Proxy Status Header Behavior." Don't assume your origin will see it by default — that's not how it works. The header flows downstream, from proxy to client, not upstream.
What we've found works best is treating Proxy-Status as a first-class logging signal, not just a debugging header you look at in curl. That means capturing it at the edge, at your origin shield, and at your client-side synthetic monitors. You'll want to log the full field value — not just the error type — because the details parameter is where 80% of the actionable info lives. If you're aggregating logs in Datadog or Grafana Loki, parse it into structured fields on ingest. Otherwise you'll be regexing it at 2 AM during an origin timeout storm, and nobody wants that. For high-traffic properties pushing 15k-20k RPS, sampling 10% of 200s but 100% of non-200s with a Proxy-Status attached will save you a fortune in log volume without losing signal.
# Nginx: capture Proxy-Status from upstream CDN/shield and log it
log_format cdn_debug '$remote_addr - $request - $status '
'proxy_status="$upstream_http_proxy_status" '
'cache_status="$upstream_http_x_cache" '
'rt=$request_time urt=$upstream_response_time';
access_log /var/log/nginx/cdn.log cdn_debug;
# Fastly VCL: ensure you don't strip it on the way out
sub vcl_deliver {
# Preserve Proxy-Status from origin/shield layers
if (resp.http.Proxy-Status) {
set resp.http.Proxy-Status = resp.http.Proxy-Status + ", Fastly; hit=" + cache_status;
} else {
set resp.http.Proxy-Status = "Fastly; hit=" + cache_status + "; ttl=" + ttl;
}
}
# Client-side fetch: pull it for RUM
const res = await fetch(url);
console.log(res.headers.get('proxy-status'));
// => "CloudFront; error=http_response_error; details=\"origin_tls_timeout\", Fastly; hit=miss"
Advanced Patterns: Debugging Multi-Tier Caches and Intermittent Failures
Here's where most teams get tripped up: Proxy-Status isn't a single header, it's a list. When a request traverses CloudFront -> Fastly shield -> your origin Nginx, you'll get two or even three Proxy-Status values back, one per proxy, in the order they were applied. That comma-separated list is gold. If you see Proxy-Status: Akamai; error=connection_refused; details="no healthy origin" followed by Proxy-Status: CloudFront; error=proxy_internal_response; details="collapsed forwarding", you know immediately the failure originated at Akamai's attempt to reach origin, and CloudFront just collapsed it. Don't collapse them into one string field in your logs — split on comma and store as an array.
The real power comes when you correlate Proxy-Status with other standardized headers. Pair it with Cache-Status and CDN-Cache-Control and you can reconstruct the entire request lifecycle without guessing. We recently debugged a 0.3% error rate on partial content where the CDN was returning 502s only on revalidation. The Proxy-Status said error=http_response_error; details="origin sent 412 on conditional" and the origin logs showed a strict If-Match comparison failing because of a weak ETag mismatch. That's a classic interaction you'd never catch without both headers. If you're doing advanced revalidation flows, our deep dive on Advanced HTTP Conditional Requests: If-Match, If-Range, and Partial Updates covers exactly how those validators break at the edge.
For intermittent errors — the ones where 99.7% of requests are fine but your p95 origin latency spikes to 1.8s — use next-hop and timing details. Some proxies include next-hop="203.0.113.45:443" or latency=23 in the details. Pipe that into your tracing system alongside your HTTP Trailers for CDN: Post-Body Checksums, Timing, and Logging pipeline. You'll start seeing that those 1.2% of dns_timeout or connection_timeout errors all hit the same origin PoP in us-east-1 during deploys. And if you're moving toward newer protocols, watch out: WebTransport Through CDN Proxies: Bidirectional Streams Over HTTP/3 introduces entirely new proxy error states for long-lived sessions that won't show up in a simple status code.
Frequently Asked Questions
Will Proxy-Status be exposed to browsers or leak origin details to users?
Yes, by default it's exposed to the client — that's the point. But you control what's in the details field. Don't put internal IP addresses, origin hostnames, or stack traces in there. Use opaque codes like origin_unreachable_shield_02 and map them internally. Most CDNs let you sanitize or redact the header at the edge before delivery, and you should for any user-facing property handling sensitive traffic.
What's the difference between Proxy-Status and the older X-Cache or CF-Cache-Status headers?
X-Cache and CF-Cache-Status only tell you hit/miss/pass. Proxy-Status tells you *why* something failed, with a standardized error taxonomy from RFC 9209. Think of Cache-Status as the success path and Proxy-Status as the failure path — you need both. We've seen teams try to infer errors from cache miss + 502 alone and waste hours that Proxy-Status would have solved in 30 seconds.
How do I handle multiple Proxy-Status headers from chained CDNs?
Don't deduplicate them. Per RFC 9209, each proxy SHOULD append its own field-value. Your log parser should split on commas not inside quotes and treat it as an ordered list from closest-to-origin to closest-to-client. The *first* entry is usually the root cause, the *last* is the edge proxy you touched. If you're using Varnish or Nginx as a shield, make sure you're using add_header not set_header so you don't overwrite upstream values.
Does Proxy-Status add measurable latency or header bloat?
No. The header is typically 60-140 bytes — trivial compared to a 1,500-byte cookie header. We haven't measured more than 0.1ms added processing time on Fastly or CloudFront for emitting it, even at 50k RPS. The only cost is log volume if you're logging it verbosely. As mentioned, sample successes heavily and log all errors — you'll get 99% of the debugging value for about 4-6% extra log ingest.