Skip to main content
Caching Strategies

Should You Cache That? A Decision Framework for HTTP Responses

Cache-aside is the default for most apps, but we need to decide what to cache and for how long. Here's the framework we use, with concrete TTLs and validation rules.

The number that should anchor every caching conversation is 80–95%: that's the typical CDN cache hit ratio, and CDNs can absorb 70% or more of origin requests (AWS caching overview). If you're not seeing those numbers, your caching strategy isn't working. The question isn't "should we cache?" — it's "what do we cache, for how long, and how do we keep it fresh?"

We'll answer that one question in depth. This is the decision framework we use when we're staring at a service that's melting under load and trying to figure out which responses deserve a cache entry and which ones should never be stored.

Start with the cache-aside pattern and a TTL

For application-level caching, the default is cache-aside (lazy loading): check the cache first, fetch from the database on a miss, then write the result back to the cache (AWS caching overview). That's the pattern we reach for unless we have a specific reason not to. It's simple, it survives cache restarts, and it degrades gracefully.

The harder decision is the TTL. Too short and you hammer the origin; too long and you serve stale data. We set TTLs by asking: how bad is it if this value is wrong for N seconds? For a product catalog that changes hourly, a 10-minute TTL is fine. For a user's account balance, zero seconds — don't cache it at all. For a session token, cache it for the session lifetime. The TTL is a business decision disguised as a technical one.

When you're using Redis for this, SETEX sets a string value with its TTL in a single command (Redis SETEX). That's the primitive. Don't do SET then EXPIRE — you'll leak keys if the process dies between the two.

For eviction, use allkeys-lru. Redis uses LRU by default, and allkeys-lru is recommended because a cache is not storage (AWS caching overview). If you're using volatile-lru and your keys don't all have TTLs, you'll get noeviction behavior when memory fills — Redis returns errors for writes instead of evicting (Redis eviction). That's a production incident waiting to happen. Set maxmemory-policy allkeys-lru and move on.

One more Redis-specific note: if you're using LFU (available since Redis 4.0), it can give a better hit ratio than LRU for some workloads (Redis eviction). But we've found LFU's tuning parameters (counter decay, log factor) add complexity that most teams don't need. Start with LRU; switch to LFU only if you can measure the improvement.

HTTP caching: the headers that actually matter

For anything served over HTTP — static assets, API responses, HTML — you're not writing cache logic yourself. You're writing Cache-Control headers and letting browsers, CDNs, and proxies do the work. Get these wrong and no amount of Redis will save you.

The freshness lifetime is determined by first matching rule: s-maxage (for shared caches) takes precedence, then max-age, then Expires minus Date, otherwise a heuristic (RFC 9111). In practice, that means: if you set both max-age and Expires, the max-age wins and Expires is ignored (RFC 9111). We see teams set Expires because "that's what the old docs said" and then wonder why their max-age isn't respected. Delete the Expires header. Use Cache-Control.

Here's how we map directives to intent:

Directive What it does When we use it
max-age=604800 Fresh for 7 days from generation Versioned static assets (JS, CSS, images with hash in filename)
s-maxage=3600 Fresh for 1 hour in shared caches; overrides max-age for CDNs API responses that are the same for all users
no-cache Store but revalidate before every reuse HTML pages that change often but are expensive to regenerate
no-store Never store, in any cache Authenticated user data, payment info, anything sensitive
private Only browser cache may store Per-user responses that are safe to cache locally
stale-while-revalidate=30 Serve stale for up to 30s while revalidating in background Content where a 30s delay is acceptable but origin load matters

The one that changed our architecture is stale-while-revalidate. RFC 5861 defines it: a cache may serve a stale response for up to the indicated seconds while revalidating it in the background. Before this, we had a choice between serving stale (bad) and blocking on revalidation (slow). Now we can say "serve the old version for 30 seconds while you fetch the new one" — and the user never waits. We use it on product pages, search results, and any API endpoint where a few seconds of staleness is invisible to the user.

For validation, ETag and Last-Modified are your tools. ETag is an identifier for a specific version of a resource; when content hasn't changed, the server doesn't need to resend the full response (MDN ETag). Last-Modified is less accurate and serves as a fallback when ETags are unavailable (MDN Last-Modified). We always send both. Some proxies and clients handle one better than the other, and the cost is a few bytes.

Quick tip: If you use the Vary header, audit it. Vary tells caches which request headers influenced the response — so Vary: User-Agent creates a separate cache entry for every user agent string (MDN Vary). That can destroy your hit ratio. We've seen teams add Vary: User-Agent "just in case" and then wonder why their CDN hit rate dropped from 85% to 40%.

Invalidation: the part everyone gets wrong

There are two ways to handle stale content: TTL and purge. We use both, but for different things.

TTL is passive. You set max-age=300 and wait five minutes. It's simple and it works. But it means your content is stale for up to five minutes after every change. For a news article, that's fine. For a price change during a flash sale, it's not.

Purge is active. Cloudflare's purge by single-file instantly removes a cached resource across all data centers, so the next request gets the latest version (Cloudflare purge). When we push a content update, we purge the specific URLs that changed. That gives us the best of both: long TTLs for cache efficiency, immediate freshness for content that matters.

The trap is that purge only works if you know what to purge. If your cache key includes a query string or a Vary header, you might purge one variant and leave others stale. We keep a mapping from content ID to cache URLs so we can purge precisely. That mapping is worth maintaining.

One more rule from the spec: unsafe methods like POST, PUT, and DELETE must invalidate the target URI in intervening caches when they return a non-error status (RFC 9111). That means your CDN should already be invalidating on writes. Check that it is. If your CDN doesn't honor this, you'll serve stale data after every update until the TTL expires.

What we actually recommend

If you're building a new service today, here's the stack we'd use:

  • Static assets: max-age=604800 (7 days) with a hash in the filename. Immutable. No revalidation needed.
  • API responses that are the same for all users: s-maxage=3600, stale-while-revalidate=60. Let the CDN do the work.
  • Per-user API responses: private, max-age=60. Cache in the browser, not the CDN.
  • Authenticated or sensitive data: no-store. Don't cache it anywhere.
  • Application-level cache: Redis with allkeys-lru and SETEX for TTLs. Cache-aside pattern.

The most important thing to remember: caching is a trade-off between freshness and load. There is no free lunch. Every TTL you set is a decision about how much staleness you're willing to accept in exchange for how much origin load you're avoiding. Make that decision deliberately, measure your hit ratio, and adjust. If you're not measuring, you're guessing.

Sources

  • AWS (caching overview) - https://aws.amazon.com/caching/
  • RFC 9111 (HTTP Caching) - https://httpwg.org/specs/rfc9111.html
  • RFC 5861 (stale-while-revalidate) - https://www.rfc-editor.org/rfc/rfc5861.txt
  • 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
  • Redis (eviction) - https://redis.io/docs/latest/develop/reference/eviction/

Share this article:

Comments (0)

No comments yet. Be the first to comment!