Key takeaways
- Cache only responses whose consumers, representations, and acceptable staleness are understood.
- Treat browser, shared HTTP, CDN, process-local, and distributed application caches as different trust boundaries.
- Build a complete cache key from every request property that can change the selected response, without storing raw secrets in the key.
- Use explicit freshness and invalidation rules. Eviction for memory pressure is not the same as business invalidation.
- Store validators such as ETag with the body and use conditional requests when the provider supports them.
- Prevent cache stampedes with request coalescing, bounded refresh work, and staggered expiration.
- Serve stale data only for operations where its age and security consequences are acceptable and visible.
1. Start with the result the cache must improve
A cache stores a reusable copy closer to the consumer. For an API client, that copy can reduce provider calls, response latency, transferred bytes, and exposure to a brief origin failure. It also introduces another place where data can become stale, leak across users, or survive longer than intended.
Write the goal before choosing a product or time to live. A cache meant to keep a public catalog fast has a different policy from a per-user cache meant to avoid duplicate reads during one request. If most keys are read once, change constantly, or require immediate consistency, the extra cache lookup and invalidation work may provide little value.
cache value = saved provider work and latency
- staleness, security, invalidation, and operating cost 2. Decide what is eligible before storing anything
Stable, frequently read representations are the clearest candidates. Public metadata, reference tables, supported regions, documentation indexes, and slowly changing catalogs often tolerate a defined amount of staleness. User balances, authorization decisions, one-time tokens, secrets, rapidly changing inventory, and safety-critical status can require fresh origin data or a tightly isolated policy.
Do not infer cacheability only from the HTTP method. HTTP defines cache behavior for understood methods and status codes, while an application cache can store any object its code allows. In both cases, the business question remains the same: could returning this stored representation to this caller, at this age, cause incorrect action or expose data?
- Identity: is the response public, tenant-specific, user-specific, or credential-specific?
- Volatility: how often can the underlying representation change?
- Consequence: what happens if a consumer receives an old value?
- Invalidation: can a known write, webhook, or version change remove the entry?
- Reuse: will enough callers request the same safe representation to justify storage?
- Retention: may this data legally and operationally remain in the chosen cache?
3. Choose the cache layer and trust boundary
A private browser cache belongs to one user agent. A shared HTTP cache or CDN can serve many users. A process-local cache belongs to one application instance. A distributed application cache can be shared by many workers, tenants, or services. The same response can be safe in one layer and unsafe in another.
RFC 9111 distinguishes private and shared HTTP caches and restricts shared reuse of authenticated responses unless an explicit directive permits it. Application caches do not automatically enforce those HTTP rules. If code copies an authenticated response into Redis or memory, that code owns the authorization partition, key completeness, encryption, access policy, retention, and purge behavior.
- Browser or private HTTP cache: close to one user, but still persistent storage on that device.
- CDN or shared HTTP cache: high reuse, but only for representations safe across the applicable audience.
- Process-local cache: very fast, but each instance can hold a different version.
- Distributed application cache: consistent reach across workers, with network, access, and outage considerations.
- Per-request memoization: removes duplicate work inside one request without cross-request retention.
4. Read HTTP cache directives precisely
Cache-Control expresses response storage and reuse rules. The no-store directive prevents storage by HTTP caches. The private directive permits private-cache storage but excludes an unqualified shared cache. The no-cache directive does not mean do not store; it requires successful validation before reuse. Public, max-age, s-maxage, must-revalidate, and Expires provide other explicit controls.
Honor provider response directives when using a compliant HTTP cache. For a custom application cache, use them as part of the source policy rather than assuming they map automatically to application storage. OWASP recommends no-store for sensitive responses and warns against relying on default behavior for protected content.
Cache-Control: no-store
Do not store in an HTTP cache.
Cache-Control: private, max-age=60
A private cache may reuse for 60 seconds.
Cache-Control: no-cache
Storage is allowed, but reuse requires validation. 5. Build a complete and normalized cache key
A cache key represents one selected response. Include every request property that can change that response: provider and environment, method, normalized path, relevant query parameters, API version, representation or media type, locale, fields, pagination position, and authorization scope. RFC 9111 uses Vary to identify request header fields that distinguish stored HTTP responses.
An incomplete key can return one user's data to another user or mix incompatible API versions. An excessively broad key wastes space and destroys reuse. Never place a raw bearer token, session identifier, or personal value in a readable key. Partition the cache by a stable internal tenant or permission-scope identifier, or use a keyed digest when a sensitive differentiator is unavoidable.
key = provider + environment + method
+ normalized_path + sorted_relevant_query
+ api_version + representation
+ tenant_or_permission_partition
Do not discard a parameter unless the origin also treats it as irrelevant. 6. Set freshness from the cost of being wrong
A time to live is a maximum freshness interval, not a promise that the entry remains present. A cache can evict an item early because of memory pressure or policy. Choose freshness per resource class from its change rate, invalidation signal, provider guidance, caller tolerance, and consequence of stale use.
Very short freshness can produce constant misses and origin traffic. Very long freshness can conceal changes. Add a small randomized offset when many entries are created together so they do not all expire and refresh at one instant. Keep a hard maximum age even when background refresh or stale-on-error behavior is allowed.
effective_ttl = base_ttl + random_uniform(-jitter, +jitter)
Example only:
base_ttl = 300 seconds
jitter = 30 seconds
range = 270 through 330 seconds 7. Revalidate with ETag or Last-Modified
Store an ETag or Last-Modified validator beside the cached body. On revalidation, send If-None-Match with the ETag or, when no ETag is available, If-Modified-Since with the saved date. RFC 9110 gives If-None-Match precedence when both are sent. A 304 Not Modified response lets the client keep the body and update applicable response metadata without transferring the complete representation again.
A conditional request still reaches the provider. Its rate-limit cost and endpoint support are provider specific. GitHub documents that a correctly authorized conditional request returning 304 does not count against its primary REST rate limit, but that behavior must not be generalized to other providers.
GET /resource HTTP/1.1
If-None-Match: "saved-etag"
304 Not Modified: keep body and refresh stored metadata
200 OK: replace body, validator, policy, and stored time 8. Design invalidation with the write path
Cache-aside reads from the cache first, loads the origin on a miss, then stores the result. On a known write, update the source of truth before removing the corresponding cache entry. Deleting the cache first creates a window where another reader can miss, fetch the old origin value, and place that stale value back into the cache.
Time-based expiry remains a fallback, not a substitute for a known invalidation event. Use direct key deletion for simple resources, versioned keys for immutable representations, and tags or dependency indexes when one write affects many keys. Webhooks and change events can shorten stale windows, but they can be delayed or duplicated, so retain a maximum age.
read:
cached = get(key)
if present and usable: return cached
fresh = fetch_from_provider()
store(key, fresh, policy)
return fresh
known write:
update_source_of_truth()
invalidate(affected_keys) 9. Prevent many misses from becoming a stampede
A popular entry can expire while many callers are waiting. If every miss starts the same provider request, the cache amplifies load at the worst moment. Coalesce concurrent refreshes so one bounded worker refreshes a key while other callers wait briefly, use an acceptable stale copy, or receive a controlled failure.
Any refresh lock or lease needs a timeout and an owner-safe release path so a crashed refresher cannot block the key forever. Bound refresh concurrency across different keys, add expiration jitter, and avoid warming the entire keyspace at once. Measure coalesced waits so a hidden refresh bottleneck does not masquerade as a high hit rate.
fresh entry: return it
stale or missing entry:
refresh already running: wait briefly or use allowed stale copy
no refresh running: acquire short lease and fetch once
refresh succeeds: replace entry and release
refresh fails: apply explicit stale or error policy 10. Make stale serving an explicit product decision
RFC 5861 defines stale-while-revalidate, which can serve a stale response for a bounded interval while revalidating in the background, and stale-if-error, which can use a stale response for a bounded interval when an error occurs. These controls improve latency or availability only when an old representation remains safe.
Do not use stale data for authorization, revoked access, one-time credentials, rapidly changing financial values, or any decision where an old answer creates material harm. Store the origin time and age, enforce a hard stale limit, and expose age to the application when users or downstream decisions need it. Never silently convert an unlimited outage into unlimited stale service.
fresh_until = stored_at + freshness_ttl
hard_until = stored_at + maximum_stale_age
now <= fresh_until: serve fresh
now <= hard_until and policy allows this condition: serve stale
otherwise: revalidate or fail 11. Cache errors and absence only by deliberate rule
Negative caching stores an absent or failed result. A short cache for a stable public 404 can prevent repeated lookups, but the complete key and authorization partition still matter. A permission-specific 404 or 403 can reveal or conceal resource existence when reused in the wrong context.
Do not put 401, 403, 429, timeout, or 5xx responses into an application cache by default. Authentication can change, throttling has its own retry timing, and transient failures should recover. Follow explicit HTTP directives and provider documentation when a response is intentionally cacheable, and use a short, status-specific limit rather than the success TTL.
- Keep success and negative entries distinguishable in telemetry and storage.
- Partition permission-dependent absence by the same authorization scope as success.
- Do not let a cached 429 replace provider Retry-After handling.
- Expire negative entries quickly when a resource can be created soon.
- Invalidate a known negative entry after a successful create or permission change.
12. Treat cached data as retained production data
A cache is not outside the security model because it is temporary. Apply least-privilege access, transport protection, encryption where required, network controls, retention limits, auditability, and deletion procedures appropriate to the stored data. Keep secrets out of values unless the design explicitly requires and protects them.
Review logs, metrics, and keys as well as values. A URL, query, tenant name, or resource identifier can expose sensitive information even when the response body is encrypted. Test that logout, permission changes, tenant deletion, privacy requests, and credential rotation invalidate every applicable layer, including browsers and shared edges when they were allowed to store the response.
- Do not cache bearer tokens, passwords, one-time codes, or secret-bearing error details.
- Do not use raw credentials as cache-key material.
- Separate tenants and permission scopes in both keys and cache access policy.
- Set storage and backup retention intentionally, including dead replicas and snapshots.
- Provide a tested purge path and record purge failures or lag.
- Protect cache write paths against untrusted key and value poisoning.
13. Measure usefulness, freshness, and origin impact
A high hit ratio can still be harmful if the cache serves old or incorrectly partitioned data. Measure hits, misses, revalidations, 304 responses, origin fetches, refresh errors, stale responses, evictions, invalidations, and purge lag. Break the data down by resource policy instead of averaging public catalogs with sensitive user records.
Track bytes saved and provider calls avoided alongside cache latency and memory. Monitor key cardinality, entry size, hot keys, stampede suppression, refresh concurrency, and hard-expired failures. Compare cached results with sampled origin responses where safe so invalidation bugs are found before a user reports them.
hit_ratio = cache_hits / cache_lookups
revalidation_not_modified_ratio = 304_responses / revalidations
stale_serve_ratio = stale_responses / cache_responses
provider_call_reduction = 1 - provider_calls_with_cache / baseline_calls 14. Compare public and user-specific policies
Consider a public provider catalog that changes a few times per day. A shared application cache can key by provider, API version, normalized path, sorted query, requested fields, representation, and locale. It uses a five-minute freshness interval with a small randomized offset, stores ETag, coalesces refreshes, and allows at most 15 additional minutes of stale service during a provider error because the catalog is descriptive rather than transactional.
Now consider the current user's quota usage. That response varies by credential and changes quickly. It is not eligible for the public shared key. The client might avoid storage entirely, use private HTTP rules from the provider, or place it in a tightly partitioned per-tenant application cache for a very short interval with no stale-on-error behavior. A permission change purges it immediately.
These policies are examples. The safe values come from the actual provider directives, representation semantics, caller permissions, update frequency, invalidation signals, and consequence of returning an old value.
public catalog:
shared key, 5-minute freshness, ETag revalidation
refresh coalescing, bounded stale-on-error
user quota usage:
private or no storage, credential-scope partition
very short freshness, no stale-on-error, purge on access change Safe response caching checklist
- Write the performance or reliability goal and confirm the data has meaningful reuse.
- Classify every response as public, tenant, user, credential, or permission scoped.
- Choose the cache layer and apply its correct trust and persistence boundary.
- Honor Cache-Control, Vary, validators, and provider-specific documentation.
- Include every response-changing request property in a normalized key.
- Keep raw credentials and unnecessary personal data out of keys, values, and logs.
- Choose freshness and maximum stale age from the consequence of an old answer.
- Store ETag or Last-Modified and test 200, 304, changed, and missing-validator paths.
- Invalidate after known writes and retain expiry as a fallback.
- Coalesce refreshes, stagger expiration, and bound refresh concurrency.
- Define status-specific negative caching instead of caching every failure.
- Test logout, permission changes, tenant deletion, provider outage, and purge failure.
- Monitor origin calls, hits, revalidation, stale service, age, evictions, and purge lag.
Sources and further reading
- RFC 9111, HTTP Caching RFC Editor
- RFC 9110, HTTP Semantics, Conditional Requests RFC Editor
- RFC 5861, HTTP Cache-Control Extensions for Stale Content RFC Editor
- Best practices for using the REST API GitHub Docs
- Cache-Aside pattern Microsoft Azure Architecture Center
- HTTP Headers Cheat Sheet, Cache-Control OWASP Cheat Sheet Series
- Cache Keys Cloudflare Documentation