When your origin server spikes during high traffic or throws a 502 in the middle of a deploy, users shouldn’t stare at a blank error page or eat a 1,200ms cold cache penalty. Wiring up stale-while-revalidate and stale-if-error lets your CDN and your local Nginx proxy serve cached content instantly while fetching fresh assets silently in the background.
I set this up on a high-throughput API running Nginx 1.24 sitting behind an edge CDN. We’ll configure both Nginx’s internal proxy cache layer and the downstream Cache-Control headers so edge nodes know how to mask origin blips and latency spikes.

How RFC 5861 Stale Directives Work
Standard caching forces a frustrating tradeoff: short TTLs keep data fresh but hammer your origin, while long TTLs risk serving stale data forever. The extensions defined in RFC 5861 fix this by splitting cache lifetimes into three distinct windows: fresh, stale-revalidating, and stale-fallback.
- max-age=N: The payload is fresh for
Nseconds. Edge nodes and browsers return this immediately without pinging the origin. - stale-while-revalidate=M: For
Mseconds aftermax-ageruns out, the cache serves the stale object instantly to the visitor while firing a background request upstream to get fresh data. - stale-if-error=K: If the origin throws a 500, 502, 503, or 504 within
Kseconds past expiration, the cache absorbs the hit and serves stale content instead of letting the user see an error.
Modern CDNs look for these directives in your origin’s response headers. But if your Nginx instance sits between a CDN and an upstream Node.js, PHP-FPM, or Python service, Nginx needs its own internal directives configured so both tiers behave consistently.
Configuring Nginx’s Internal Proxy Cache for Stale Content
Before worrying about CDN headers, get Nginx’s internal proxy_cache in order. This protects your app workers from nasty cache stampedes when a hundred requests hit an expired key at the exact same millisecond.
Open your main Nginx config (usually /etc/nginx/nginx.conf) and declare your cache zone inside the http block:
# /etc/nginx/nginx.conf inside http {} block
proxy_cache_path /var/cache/nginx/edge_cache levels=1:2 keys_zone=APP_CACHE:50m max_size=2g inactive=24h use_temp_path=off;Next, open your site-specific config file (like /etc/nginx/sites-available/app.conf). Turn on proxy_cache_use_stale and enable background updates:
server { listen 80; server_name app.example.com; location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Internal Nginx Stale Caching proxy_cache APP_CACHE; proxy_cache_key "$scheme$request_method$host$request_uri"; proxy_cache_valid 200 302 5m; proxy_cache_valid 404 1m; # Serve stale on errors, timeouts, and while fetching fresh copies proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; proxy_cache_background_update on; proxy_cache_lock on; proxy_cache_lock_timeout 5s; # Debug header to verify cache state add_header X-Nginx-Cache $upstream_cache_status always; }
}The proxy_cache_use_stale ... updating directive tells Nginx to serve whatever’s currently in the local cache while a background worker revalidates it. Adding proxy_cache_lock on makes sure only one upstream request goes out at a time, completely killing the thundering herd problem.
Emitting Stale Headers Downstream to the Edge CDN
Your edge CDN needs explicit Cache-Control headers to trigger its own edge-level background fetches. Check our guide on configuring CDN edge cache rules to ensure cookie bypass logic isn’t overriding your global headers.
If your upstream application doesn’t emit custom headers, inject them at the Nginx layer using add_header. If it does, adjust or override them carefully:
location /api/products { proxy_pass http://127.0.0.1:3000; # Strip conflicting Cache-Control from upstream if needed proxy_hide_header Cache-Control; proxy_hide_header Pragma; # Cache for 60s, stale for 10 min during revalidation, stale for 24h on error add_header Cache-Control "public, max-age=60, stale-while-revalidate=600, stale-if-error=86400" always; # Surrogate header specifically for CDNs supporting Surrogate-Control add_header Surrogate-Control "max-age=300, stale-while-revalidate=900, stale-if-error=86400" always;
}You can also reference the standard MDN Cache-Control documentation to verify browser quirks and exact syntax rules for complex header chains.
Handling the Origin Shield and Tiered Edge Routing
With an origin shield or multi-tier CDN layout, traffic moves from Browser → Edge PoP → Origin Shield → Nginx Origin. You can check our breakdown on how to configure CDN origin shield and tiered caching for architectural layouts.
Keep these lifecycle details in mind across multi-tier setups:
- Edge PoP Revalidation: When an edge PoP hits an expired object,
stale-while-revalidatelets it return stale content immediately to the client while querying the Origin Shield in the background. - Shield Layer Protection: If the Origin Shield already pulled a fresh copy from Nginx, the edge PoP gets it in ~15ms without touching your physical origin.
- Error Shielding: If your Nginx server restarts or throws a gateway error, the Origin Shield catches it and returns its cached stale asset, protecting all edge PoPs globally.
Make sure your CDN doesn’t strip or rewrite the stale-while-revalidate parameter between tiers. In your CDN control panel, make sure “Honor Origin Cache Headers” is turned on.
Testing with cURL: Verifying Background Updates
To verify stale serving works properly, hit your endpoint multiple times with curl. Watch the response times, HTTP status codes, and cache headers.
Run this against your endpoint:
# Check the initial cold or warm cache status
curl -I -s https://app.example.com/api/products | grep -E -i "(http/|cache-control|x-nginx-cache|age|cf-cache-status|x-cache)"Here’s what came back during my tests:
HTTP/2 200 cache-control: public, max-age=60, stale-while-revalidate=600, stale-if-error=86400
age: 74
x-nginx-cache: STALE
x-cache: HITNotice age: 74 exceeds the 60-second max-age, but the server still returned an immediate HTTP/2 200. The Nginx cache logged STALE (or UPDATING), meaning it returned the cached response in 4ms while kicking off an upstream revalidation in the background.
If you see strange redirect loops during CDN testing, check our guide to fix SSL ERR_TOO_MANY_REDIRECTS on Nginx to make sure forwarded proto headers are intact.
Simulating an Origin Outage to Test Stale-If-Error
Don’t wait for a real incident to test whether stale-if-error works. Kill your upstream application process on staging and verify the fallback firsthand.
Here’s how to test it:
- Warm the cache with an initial request:
curl -s -o /dev/null -w "%{http_code}n" https://app.example.com/api/products(You should see200). - Stop your upstream app (e.g.,
systemctl stop pm2-rootordocker stop app-backend). - Fire another request immediately:
curl -I https://app.example.com/api/productsNginx gets a 502 Connection Refused from 127.0.0.1:3000. But because proxy_cache_use_stale error http_502 is active, Nginx intercepts the failure and returns the cached object with an HTTP 200.
If your requests carry dynamic query strings, check out our guide to strip marketing query strings at the CDN edge so campaign tags don’t bypass your cache rules entirely.
Troubleshooting Gotchas: FastCGI, Buffering, and Headers
A few common Nginx traps can silently break stale revalidation:
1. The add_header Inheritance Pitfall
In Nginx, defining add_header inside a child location block wipes out all add_header directives from parent server and http blocks. Always run curl -I to make sure your Cache-Control header wasn’t accidentally swallowed by a child security header block.
2. FastCGI and PHP-FPM Configuration
If you’re running WordPress or standard PHP-FPM instead of a reverse proxy, swap the proxy_cache_* directives for their fastcgi_cache_* equivalents:
fastcgi_cache_use_stale error timeout updating http_500 http_503;
fastcgi_cache_background_update on;
fastcgi_cache_lock on;For more details on all available stale parameters, check the official Nginx proxy_cache_use_stale documentation.
FAQ: Stale-While-Revalidate & Stale-If-Error
Does stale-while-revalidate work for authenticated user sessions?
No. Never send stale-while-revalidate alongside public on endpoints containing user-specific cookies, profile data, or CSRF tokens. Stick to public product catalogs, feeds, marketing routes, and static assets.
Will stale-if-error mask actual backend crashes from monitoring tools?
Yes. If your synthetic uptime monitor checks your public pages through the CDN, it’ll get 200 OK responses even while your origin backend is burning. Point your uptime monitors at an un-cached origin health check endpoint (like https://origin.example.com/healthz) so you catch failures immediately.
What happens if the background revalidation request fails?
If the background fetch triggered by stale-while-revalidate fails with a 5xx or times out, the cache keeps serving the stale entry—provided stale-if-error or proxy_cache_use_stale error is configured. It’ll keep serving that copy until the stale-if-error window runs out.
Next Steps for Edge Performance
Once your stale directives are dialed in, take a close look at your edge topology. Read our hands-on walkthrough on how to configure CDN origin shield and tiered caching to collapse background revalidation requests and protect your backend from multi-region traffic spikes.

