Who This Is For
If you're running a web app that's slowing down, your first instinct is probably to throw more hardware at it. That's wrong. Before you scale out, you need to tune what you already have—specifically, your HTTP cache headers. This guide is for developers and ops folks who own a web service and want to squeeze more performance out of it without adding a single server. We'll walk through the concrete steps I take when I'm asked to make a sluggish system faster.
Step 1: Set Explicit Cache-Control Headers
Start with the basics. The Cache-Control header is the master switch for HTTP caching. It can carry multiple directives separated by commas, like max-age=180, public (MDN). The max-age directive tells caches how long a response is fresh, counting from when the origin generated it, not when it was received (MDN). For example, Cache-Control: max-age=604800 keeps a response fresh for seven days.
But here's the kicker: if you're using a CDN or a shared cache, you should also think about s-maxage. That directive overrides max-age for shared caches, letting you have a shorter freshness for the browser and a longer one for the CDN. Per RFC 9111, when a response carries max-age, recipients must ignore the Expires header (RFC 9111). So stop setting Expires and start using Cache-Control properly.
What can go wrong: If you set a long max-age on a resource that changes frequently, users will see stale content. That's why you need to think about the next step.
Step 2: Use Validators and Handle Dynamic Content
For resources that do change, you need validators. An ETag is a unique identifier for a specific version of a resource, and it lets the server skip sending the full response if the content hasn't changed (MDN). Last-Modified is a fallback, but it's less accurate (MDN). Use ETags for anything that's not completely static.
Dynamic content is trickier. If a response varies based on the Accept-Encoding header or cookies, you need the Vary header. The Vary header lists the request headers that influenced the response, so caches store separate copies for different values of those headers (MDN). For example, if you serve different content to mobile users based on the User-Agent, you must set Vary: User-Agent or caches will serve the wrong version.
What can go wrong: Forgetting Vary is a silent killer. You'll see weird bugs where users get the wrong content, and you'll waste hours debugging. Always check that your dynamic responses set Vary appropriately.
Step 3: Leverage Stale-While-Revalidate for Resilience
Now, here's a trick that many people overlook: stale-while-revalidate. This Cache-Control extension lets a cache serve a stale response while it revalidates in the background (RFC 5861). That's perfect for content that's not time-sensitive, like a blog post or a product description. It keeps latency low and avoids thundering herds.
For example, you can set Cache-Control: max-age=60, stale-while-revalidate=600. That means the resource is fresh for 60 seconds, but for up to 10 minutes after that, the cache can serve the stale version while fetching a new one in the background. This can dramatically reduce origin load.
What can go wrong: If you use stale-while-revalidate on a resource that must be real-time (like a stock price), users will see outdated data. Use it only for content that can tolerate a few minutes of staleness.
Step 4: Consider a CDN Layer and Purge Strategically
If you're not using a CDN, you're leaving performance on the table. A CDN caches content closer to users, and typical hit ratios reach 80% to 95%, absorbing 70% or more of origin requests (AWS). That's a huge win.
But CDNs come with their own quirks. For instance, Cloudflare caches certain HTTP status codes by default when no cache headers are present: 200/206/301 for 120 minutes, 302/303 for 20 minutes, 404/410 for 3 minutes (Cloudflare). That means a 404 page could be cached for 3 minutes, which is usually fine, but you should be aware.
When you need to update a cached resource, use a purge. Cloudflare's purge by single-file instantly removes a resource from all data centers (Cloudflare). That's the precise way to invalidate, rather than waiting for TTLs to expire.
What can go wrong: If you purge too aggressively, you'll increase origin load. If you don't purge at all, users see stale content. Find the balance by using short TTLs for dynamic content and explicit purges for critical updates.
What I'd Actually Do
Here's my concrete recommendation: Start by auditing your existing cache headers. Set explicit Cache-Control headers with max-age and s-maxage on all static assets. Use ETags for dynamic resources. Add Vary where needed. Then, for any content that can tolerate staleness, add stale-while-revalidate. Only after you've done that, consider adding a CDN if you don't have one. And remember, Redis or Memcached are last resorts—not first steps. You'll often find that proper header tuning reduces origin load enough that you can delay or avoid scaling your database.
Stop adding servers. Tune your headers first.
Sources
- MDN (Cache-Control) - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
- RFC 9111 (HTTP Caching) - https://httpwg.org/specs/rfc9111.html
- RFC 5861 (stale-while-revalidate) - https://www.rfc-editor.org/rfc/rfc5861.txt
- Cloudflare (default cache behavior) - https://developers.cloudflare.com/cache/concepts/default-cache-behavior/
- AWS (caching overview) - https://aws.amazon.com/caching/
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!