You've got a load balancer in front of your app, and you're wondering: "How do I set Cache-Control headers so my CDN and browser caches actually work, without my load balancer serving stale data?" This is the question that decides whether your caching layer is a performance win or a debugging nightmare. Let's break it down.
The Real Question: What Should Your Cache-Control Header Say?
When you're tuning performance, the first thing to get right is the Cache-Control header. It's the single most powerful knob you have, but it's easy to misconfigure. The header can carry multiple directives separated by commas—like Cache-Control: max-age=180, public—and each directive changes behavior in a specific way (MDN). The key is to pick the right combination for your content type.
For static assets, you want a long max-age. The directive keeps a response fresh for N seconds after it was generated on the origin, not after it's received (MDN). So max-age=604800 keeps a response fresh for 7 days. That's great for images, CSS, and JS that rarely change. But for dynamic content, you need to be careful: if you set a long max-age on a response that changes, users will see stale data until the cache expires.
That's why you need to think about your load balancer. If you're using nginx as a reverse proxy, it can cache responses too, but it respects the Cache-Control header from the origin. So the header you set on your app's response dictates what nginx and any CDN in front of it will do. Get it wrong, and you'll have a chain of caches all serving the same stale content.
No-Store vs. No-Cache: The Difference That Saves You
Two directives are often confused: no-store and no-cache. no-store forbids any cache—private or shared—from storing the response (MDN). That's for sensitive data like bank balances. no-cache allows storage but requires revalidation with the origin before each reuse (MDN). That's for content that changes frequently but you still want to cache the bytes.
Here's where the load balancer comes in. If your app returns no-cache, nginx will store the response but must revalidate it on every request. That revalidation is a conditional GET to your origin—which is cheap if you use validators like ETag or Last-Modified. But if you don't have validators, revalidation means a full round-trip to the origin, and you've lost the benefit of caching. So no-cache is only useful when you have validators in place.
My rule of thumb: use no-store for anything that must never be cached, use no-cache only when you have ETags, and use long max-age for static assets. That's the foundation of a sane caching strategy.
Vary: The Header That Prevents Cache Poisoning
One of the sneakiest problems behind a load balancer is the Vary header. It tells caches which request headers influenced the response, so they store separate copies per header value (MDN). For example, if you serve different content based on Accept-Encoding, you need Vary: Accept-Encoding to prevent serving gzipped content to a client that doesn't support it.
But Vary can also kill your cache hit ratio. If you set Vary: User-Agent for responsive design, every different User-Agent string gets its own cache entry. That fragments your cache. The same problem happens with cookies: if you set Vary: Cookie, every user with a different cookie gets a separate cache entry.
So when tuning, ask: does this response really vary by that header? If not, don't add it. And if you must vary, make sure your load balancer and CDN are configured to handle the extra cache keys. nginx's proxy cache keys can be customized, but by default they include the host and URL—plus the Vary headers if you enable proxy_cache_key (Nginx).
Stale-While-Revalidate: The Performance Trick You're Missing
Now for the part that separates the pros from the amateurs: stale-while-revalidate. This directive, defined in RFC 5861, allows a cache to serve a response that is stale for up to the indicated seconds while revalidating it in the background (RFC 5861). It's a game-changer for performance because it eliminates the latency spike on cache misses.
Here's the scenario: you have a product page that updates every few minutes. You set max-age=60. After 60 seconds, the cache is stale. Without stale-while-revalidate, the next request goes to the origin, waits for a fresh response, and the user sees a delay. With stale-while-revalidate=300, the cache serves the stale copy immediately and fetches a fresh one in the background. The user gets instant response, and the origin gets updated.
But beware: stale-while-revalidate only works if the cache supports it. Cloudflare does, and nginx's proxy caching does not by default—you'd need to implement it yourself. So if you're using nginx as a load balancer/cache, you might be stuck with synchronous revalidation unless you switch to a CDN or add custom logic.
Another extension is stale-if-error, which lets a cache serve a stale response if the origin returns an error like a 500 (RFC 5861). That's a nice safety net, but it can mask real problems, so use it sparingly.
What I'd Actually Do
Here's my concrete recommendation for a typical web app behind nginx or a CDN:
- Set
Cache-Control: public, max-age=604800, immutablefor static assets (images, CSS, JS) that have versioned filenames. Theimmutabledirective tells caches not to revalidate, which is safe because the URL changes when content changes (MDN). - For HTML pages, use
Cache-Control: no-cacheand ensure you have ETag or Last-Modified validators. This forces revalidation but allows conditional GETs, which are cheap. - For API responses that change often, use
max-age=0, must-revalidateto ensure caches don't serve stale data.must-revalidatemeans a stale response cannot be reused until it's validated (RFC 9111).
And if you're using a CDN like Cloudflare, remember that by default they cache certain status codes for a fixed time when no Cache-Control is present (Cloudflare). That's a trap: you might think you're not caching, but Cloudflare is caching 404s for 3 minutes. So always set explicit headers.
One more thing: if you're using a load balancer with health checks, don't let the health check responses pollute your cache. Make sure your health check endpoint returns no-store so it doesn't get cached and served to real users.
In short, my go-to is: public, max-age=604800, immutable for static, no-cache with validators for dynamic, and stale-while-revalidate if you're on a CDN that supports it. That gives you high cache hit ratios without sacrificing freshness.
Quick tip: Always test with curl -I to see what headers your origin emits, and check the Age header to see if you're getting a cache hit. The Age header tells you how long ago the response was generated (RFC 9111). If Age is 0, you're hitting the origin; if it's >0, you're getting a cached copy.
Sources
- MDN (Cache-Control) - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
- MDN (Vary) - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Vary
- RFC 5861 (stale-while-revalidate) - https://www.rfc-editor.org/rfc/rfc5861.txt
- RFC 9111 (HTTP Caching) - https://httpwg.org/specs/rfc9111.html
- Cloudflare (default cache behavior) - https://developers.cloudflare.com/cache/concepts/default-cache-behavior/
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!