It was 2:47 AM on a Black Friday when my phone blew up. Our primary origin in us-east-1 had gone completely dark, and our edge was dutifully returning perfect 502s to about 40 million users. We'd spent months tuning cache hit ratios and optimizing TTFB, but we hadn't seriously planned for what to do when there was simply nothing to fetch. That night taught me that true CDN resilience isn't about preventing failure — it's about deciding exactly how you'll fail.
The Night Your Origin Disappears: Rethinking Failure as a Feature
For years, I treated origin health as a binary state: either it's up and we serve fresh content, or it's down and we apologize. That mindset is common, and it's wrong. Users don't care why your origin is failing; they care whether they can still accomplish something useful. That's where graceful degradation comes in. Instead of a hard error, you offer a slightly older, slightly simpler, or even synthetic version of the content that keeps the experience alive.
I've seen teams try to solve this by just increasing origin timeouts or adding more retries. All that does is turn a fast failure into a slow, cascading one. The edge tier gets saturated holding connections open, and suddenly your CDN is the bottleneck instead of the shield. A better approach, and one we eventually codified in our SRE Practices for CDN: Applying Site Reliability Engineering to Content Delivery, is to treat degraded responses as a first-class status. When I design a new delivery config now, I ask "what's the fallback?" before I ask "what's the happy path?"
The HTTP standards actually give us a great toolkit for this if you read them closely. Directives like stale-while-revalidate and stale-if-error defined in the HTTP Working Group Specifications were built precisely for this scenario, and the guidance on offline patterns in web.dev Articles shows how stale content can preserve user experience far better than an error page ever could.
# Varnish VCL - serving stale on origin failure is not an afterthought
sub vcl_hit {
if (obj.ttl >= 0s) {
# Normal hit, deliver fresh
return (deliver);
}
# TTL expired, but can we serve stale while we revalidate?
if (obj.ttl + obj.grace > 0s) {
return (deliver);
}
}
sub vcl_backend_response {
# Keep stale object for 24h for fallback content
set beresp.grace = 24h;
# Allow serving stale for 60s while fetching fresh in background
set beresp.http.Cache-Control = "max-age=300, stale-while-revalidate=60, stale-if-error=86400";
}
sub vcl_backend_error {
# Critical: If origin throws 5xx, try to find stale object
if (stale.exists) {
return (deliver_stale);
}
}
Building Your Fallback Ladder: From Stale Cache to Synthetic Responses
Not all fallback content is created equal, and you shouldn't have just one plan. I like to think in terms of a ladder — four rungs that you descend as the outage gets worse. If you only have one fallback, you'll either serve it too aggressively or not at all. The ladder we use with most commerce and media clients looks like this: first, serve stale cache; second, serve a static shell; third, serve a synthetic lightweight response generated at the edge; and only then, as a last resort, serve an error.
Stale cache is your best friend and it's almost free. If you've set a sensible grace period, you can keep serving the last known good version of a product page, article, or API response for hours even when the origin is unreachable. The next rung is static fallback — for example, a pre-deployed, ultra-light HTML shell for your homepage that says "we're experiencing delays, but here's what you can still do." We generate these at the edge with Workers or Lambda@Edge so they don't depend on any origin at all. I've used this pattern to keep checkout flows limping along during database failovers; customers could still browse and add to cart, just not get real-time inventory. That's degraded mode done right — limited function is infinitely better than no function.
This tiered thinking also ties directly into how you manage risk. You can't promise to build every fallback at once, so you have to prioritize. Our approach to CDN Error Budget Management: Balancing Reliability with Feature Velocity really helps here — we spend error budget on building fallbacks for the highest-traffic, highest-revenue paths first. And once those fallbacks are built, you must automate their validation, or they'll rot. That’s why I push teams toward the kind of checks described in CDN Toil Reduction Through Automation: Eliminating Repetitive Operations Work; a fallback you've never tested is just a theory.
// Cloudflare Worker / Fastly Compute@Edge - Tiered fallback logic
async function handleRequest(event) {
const cache = caches.default;
let response = await cache.match(event.request);
try {
// Try origin with short, aggressive timeout
const originResponse = await fetch(event.request, { cf: { cacheTtl: 300 } });
if (originResponse.ok) {
event.waitUntil(cache.put(event.request, originResponse.clone()));
return originResponse;
}
throw new Error(`Origin status: ${originResponse.status}`);
} catch (err) {
// Ladder Step 1: Return stale if we have it
if (response) {
response = new Response(response.body, response);
response.headers.set('X-Fallback', 'stale-cache');
response.headers.set('X-Degraded-Reason', err.message);
return response;
}
// Ladder Step 2: Return static shell from KV/R2
const staticFallback = await STATIC_STORE.get("fallback-shell.html");
if (staticFallback) {
return new Response(staticFallback, {
headers: { 'Content-Type': 'text/html', 'X-Fallback': 'static-shell' },
status: 200
});
}
// Ladder Step 3: Synthetic minimal JSON for API routes
return new Response(JSON.stringify({ status: "degraded", retryAfter: 60 }), {
headers: { 'Content-Type': 'application/json', 'X-Fallback': 'synthetic' },
status: 503
});
}
}
Defining "Good Enough": SLIs and Thresholds for Degraded Mode
You can't improve what you don't measure, and most teams have no SLIs for their failure modes. They measure success perfectly — p95 latency, 99.9% availability — but their dashboards go blank when things degrade. We had to learn this the hard way. During that Black Friday incident, our main availability chart stayed at 99.5% because we were technically "serving" 502s quickly. The business was losing millions, but the SRE dashboard looked fine. Now we track degraded responses as a separate, explicit class of traffic.
For any serious CDN resilience strategy, you need to instrument your edge to tag every response with its fallback tier. Is this fresh, stale, static, or synthetic? Once you have that tag, you can build SLIs that actually reflect user experience during an outage. A common target we set is that 99% of requests during an origin outage should still receive a non-5xx degraded response within our normal latency budget. If you can't hit that, your fallback isn't really working — it's just a different way to fail.
| Metric (Edge-Measured) | Normal Mode Target | Degraded Mode Target | Alert Threshold |
|---|---|---|---|
| Successful Responses (2xx + Degraded 200) | > 99.95% | > 99.0% (with X-Fallback header) | < 98.5% for 2 min |
| p95 TTFB at Edge | < 120 ms | < 180 ms (stale/static allowed) | > 300 ms for 5 min |
| Cache Hit Ratio / Fallback Serve Rate | > 92% hit ratio | > 85% served from stale/static | < 70% fallback success |
| Origin 5xx Error Rate | < 0.1% | Up to 100% (expected) | Triggers degraded SLI view |
| Synthetic Fallback Error Rate | 0% (should not be used) | < 0.5% | > 1% for 1 min |
Putting It Into Practice: Edge Logic for Your Fallback Ladder
All the strategy talk in the world doesn't matter if your edge config still hard-fails on a 502. In production, graceful degradation lives or dies in three places: your cache-control headers, your edge compute logic, and your origin health checks. We've seen teams nail the architecture diagram and then ship a 30-second origin timeout that ties up every edge worker during an outage. Don't do that. You want short, aggressive timeouts at the edge — we typically run 2s connect, 5s first-byte — combined with generous stale windows.
The workhorse here is stale-while-revalidate and stale-if-error. Set stale-while-revalidate=60 and stale-if-error=86400 on cacheable 200s and you'll survive most blips without anyone noticing. On Fastly, Cloudflare, or Akamai, that means the edge will serve a 6-hour-old asset instantly while it revalidates in the background, and if your origin is throwing 500s, it'll happily serve that stale object for a full 24 hours. Pair that with tiered shielding — we run a single shield POP per region — so you only have one cache miss storm hitting origin instead of 150 POPs. It cuts origin load by 80-90% during recovery.
But stale cache only gets you so far. You need synthetic fallbacks in code for when even stale isn't available. That's where edge workers / VCL / EdgeWorkers come in. The pattern isn't complicated, but you've got to automate the failover cleanly. As we covered in The CDN Automation Reliability Paradox: When Automation Causes More Outages, a badly tested auto-failover can cause more damage than the outage itself, so test this with fault injection, not just in staging. Here's the Fastly VCL pattern we use for API and image fallbacks:
sub vcl_fetch {
# Keep stale for 7 days for fallback
set beresp.stale_if_error = 7d;
set beresp.stale_while_revalidate = 60s;
if (beresp.status >= 500 && beresp.status < 600) {
if (stale.exists) {
return(deliver_stale);
}
# Synthetic JSON for APIs, placeholder image for media
if (req.url ~ "^/api/") {
synthetic {"{"error":"degraded","data":null,"retry_after":30}"};
set resp.status = 200;
set resp.http.Content-Type = "application/json";
set resp.http.X-Cache-Fallback = "synthetic-api";
return(deliver);
}
}
}
sub vcl_error {
if (obj.status == 503 && stale.exists) {
return(deliver_stale);
}
}
Advanced Patterns & Troubleshooting When Degraded Isn't Working
Once you've got the basics, the edge cases will bite you. The biggest one we see: cache poisoning during degradation. If you aren't careful, you'll cache that synthetic 200 or that 5-second-old stale error page for an hour and serve it after origin recovers. Always set synthetic responses to Cache-Control: no-store and tag stale-serve hits with a header like X-Served-Stale: 1 so you can purge and measure. Another gotcha is thundering herd on revalidation. Don't let 10k requests all trigger origin fetches at once — enable request coalescing / origin shielding and add a 1-2 second wait coalesce window. Without it, we've watched origins that could've recovered in 30 seconds get hammered for 8 minutes straight.
You also need to decide explicitly when to fail open vs. fail closed. For static assets, images, and GET APIs, fail open all day long — a slightly stale product price is better than no page. For checkout, auth, or POSTs, you *must* fail closed with a clear 503 + Retry-After. We use a circuit breaker in the worker: if origin error rate exceeds 50% over a 30-second window (measured via 5xx counts in edge KV), we short-circuit and skip origin entirely for 60 seconds, serving only stale/synthetic. It prevents that flapping state where origin is half-up and response times spike to 4-5 seconds. Your monitoring has to reflect this — alert on degraded_serve_ratio, not just 5xx. If you're serving 30% stale for 10 minutes, that's an incident even if status codes are 200.
When it all goes wrong anyway, your runbook is your lifeline. Don't rely on someone remembering which purge command clears synthetic flags at 3am. This is exactly why we push teams toward CDN Runbook Automation: Converting Manual Procedures into Executable Scripts — turning that "serve stale for 1 hour" playbook into a single Slack slash command that flips a KV flag. And after you survive, do the real work. A good review isn't about blame, it's about finding why your stale window was only 5 minutes. Our template from CDN Postmortem Analysis: Learning from Incidents to Prevent Recurrence forces you to ask: Did we alert on degraded SLI breach within 2 minutes? Did fallback content meet our "good enough" threshold? If you don't measure it, you won't fix it.
Frequently Asked Questions
How long should I keep stale content for fallback?
It depends on content type, but we use a ladder: 1 minute stale-while-revalidate for real-time APIs, 1 hour stale-if-error for product pages, and up to 7 days for static assets like images, JS, and CSS. Anything longer than 7 days and you're risking serving dangerously outdated content. Set it via beresp.stale_if_error and monitor your X-Served-Stale rate — if you're regularly serving 3-day-old content, your origin health checks are too lax.
Will serving stale content hurt SEO or cache poisoning?
Not if you handle headers correctly. Stale serves should still return the original status code (200) but add X-Cache: STALE and Cache-Control: no-store on synthetic responses. Never cache a synthetic fallback — give it a 0s TTL. For SEO, Googlebot will treat a stale 200 as a normal page, which is exactly what you want versus a hard 503 that de-indexes you after 24 hours.
Should I use synthetic responses for all failed requests?
No. Use them only for safe, idempotent GETs where "good enough" is acceptable. For transactional paths — cart, checkout, login, payment APIs — return a proper 503 with a JSON error and Retry-After: 30 plus a branded static error page. Faking a successful checkout response is far worse than a clear error. We map this per route in the edge worker config.
How do I test graceful degradation without causing an outage?
Use fault injection at the edge. Most CDNs let you flip an origin to "fail" via a KV flag or header. We run weekly "Game Day" tests where we set origin_error=1 for 5% of traffic and verify stale serve rate jumps to 5% with p95 latency staying under 80ms. Also test purge-after-recovery — make sure that stale flag clears within 60 seconds once origin is healthy again.