Configure Stale-While-Revalidate and Stale-If-Error on Nginx Behind a CDN

by Fahim

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.

Nginx server rack running caching directives in a modern edge data center
Nginx server rack running caching directives in a modern edge data center

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 N seconds. Edge nodes and browsers return this immediately without pinging the origin.
  • stale-while-revalidate=M: For M seconds after max-age runs 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 K seconds 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:

  1. Edge PoP Revalidation: When an edge PoP hits an expired object, stale-while-revalidate lets it return stale content immediately to the client while querying the Origin Shield in the background.
  2. 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.
  3. 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: HIT

Notice 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:

  1. Warm the cache with an initial request: curl -s -o /dev/null -w "%{http_code}n" https://app.example.com/api/products (You should see 200).
  2. Stop your upstream app (e.g., systemctl stop pm2-root or docker stop app-backend).
  3. Fire another request immediately:
curl -I https://app.example.com/api/products

Nginx 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.

all_in_one_marketing_tool