API Caching in Production: Cache-Control, ETags, CDNs, and Redis
API caching can happen at several layers: inside the browser, at a CDN or reverse proxy, in an API gateway, or inside the application with Redis. Those layers solve different problems, and treating them as one generic "cache" is how stale data, privacy leaks, and confusing invalidation bugs appear.
The first design question is therefore not "which Redis command should I use?" It is where should this response be cached, who is allowed to reuse it, and how fresh must it be?
Four common API cache layers
| Layer | Best for | Main risk |
|---|---|---|
| Browser/private HTTP cache | responses reusable by one client | stale client state |
| CDN/shared proxy | public responses reusable across many clients | leaking personalized data |
| API gateway | centralized route-level response caching | overly broad cache keys |
| Redis/application cache | expensive upstream or computed responses | application-managed invalidation |
Many production systems use more than one layer. A public catalog endpoint might be cached at the CDN and also use Redis behind the origin. A personalized account endpoint might use only a private browser cache or no HTTP caching at all.
Start with HTTP semantics before adding Redis
HTTP already has a standardized caching model defined by RFC 9111. For cacheable API responses, the most important control is usually the Cache-Control response header.
A public response that may be reused for one minute can look like:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=60
{"id":42,"name":"Keyboard"}
A response containing user-specific data should normally not be reusable by a shared CDN cache:
Cache-Control: private, max-age=60
And when a response may be stored but must be revalidated before reuse:
Cache-Control: no-cache
no-cache does not mean "do not store." It means the stored response must be validated before it can be reused. If storage itself must be prohibited, no-store is the relevant directive.
That distinction matters for authenticated APIs, debugging, and compliance-sensitive data.
Public and private are security decisions
A shared cache can serve one stored response to many users. That is excellent for public data and dangerous for personalized data.
Before marking an API response public, ask whether the body varies by:
- authenticated user
- tenant or organization
- subscription plan
- authorization scope
- locale
- currency
- feature flags
- request headers
If the response differs by security context, either keep it out of shared caches or make the cache key correctly include the relevant variation. A fast response delivered to the wrong user is a data leak, not a performance optimization.
Use Vary when request headers change the representation
HTTP's Vary response header tells shared caches which request headers participate in choosing the correct stored response.
For an API that returns different content encodings:
Vary: Accept-Encoding
If an API intentionally returns different representations based on another request header, that header may also need to appear in Vary.
Do not casually use Vary: * or vary on many high-cardinality headers. Every added dimension reduces cache reuse and increases the number of stored variants.
Authorization deserves special care. Do not assume adding an authorization header to a key automatically makes a shared-cache design safe. Personalized authenticated responses are commonly better kept private unless the caching architecture was explicitly designed for them.
ETags reduce payload transfer without serving stale data blindly
Some API responses change infrequently but still need freshness checks. HTTP validators solve this without forcing the full body to be transferred every time.
The server returns an ETag:
HTTP/1.1 200 OK
ETag: "product-42-v17"
Cache-Control: no-cache
Content-Type: application/json
Later, the client or intermediary can ask whether that representation is still current:
If-None-Match: "product-42-v17"
If nothing changed, the server can return:
HTTP/1.1 304 Not Modified
The cached body is then reused without downloading the representation again.
RFC 9111 defines this validation flow, and current MDN guidance explains ETag / If-None-Match as the preferred validator mechanism when an entity tag is available.
Last-Modified is useful but less precise
A server can also send:
Last-Modified: Sat, 12 Sep 2026 05:30:00 GMT
A client may later revalidate with:
If-Modified-Since: Sat, 12 Sep 2026 05:30:00 GMT
This can be useful when the underlying resource already has a trustworthy modification timestamp. ETags are often easier to make representation-specific, especially when API output combines several underlying records.
If both validators are present during normal cache validation, If-None-Match takes precedence over If-Modified-Since according to HTTP semantics.
CDN caching works best for broadly reusable responses
A CDN can remove enormous amounts of traffic from the origin when many users request the same public representation.
Good candidates include:
- public product/category data
- documentation APIs
- public configuration that changes infrequently
- geographic/reference datasets
- anonymous search results with bounded variation
Poor candidates include:
- account pages
- private invoices
- per-user permissions
- mutable checkout state
- responses whose output is difficult to key safely
When supported by your CDN, shared-cache freshness can be controlled separately from browser freshness. The exact directives and behavior vary by provider, so verify the CDN's documentation instead of assuming every intermediary interprets provider-specific extensions identically.
Redis solves a different caching problem
HTTP caches reuse HTTP responses according to protocol semantics. Redis usually caches application data or serialized responses inside your backend.
Redis is especially useful when:
- an upstream API is slow or rate-limited
- generating the response requires expensive database or service calls
- several application instances should share one cached result
- the response cannot be effectively cached by clients/CDNs
- the backend needs explicit invalidation control
A simple application response cache can look like:
key = api-cache:weather:agra
cached = Redis GET key
if cached exists:
return cached
response = call upstream API
Redis SET key response EX 120
return response
Redis' current official API-caching and cache-aside material uses the same core model: read Redis first, call the slower origin on a miss, and store the result with a TTL.
Build Redis API keys from response semantics
Do not key a Redis response cache only by route name if query parameters or user context change the result.
For example:
api-cache:products:category:keyboards:page:2:currency:INR
A stable cache key may need normalized versions of:
- route/path
- relevant query parameters
- tenant ID
- locale
- currency
- API version
- authorization scope if caching private data deliberately
Normalize parameters so logically identical requests do not create separate entries merely because query-string ordering differs.
Never cache unsafe responses accidentally
Before caching a response, define which methods and status codes are eligible.
A conservative application cache often begins with successful GET responses only.
Be careful with:
POST,PATCH,PUT, andDELETE- authentication endpoints
- one-time tokens
- signed URLs
- password-reset flows
- rapidly changing authorization results
- transient upstream errors
RFC 9111 requires unsafe requests to reach the origin rather than being answered directly from a normal HTTP cache. Application-level Redis caches should follow similarly deliberate semantics instead of caching arbitrary handlers.
Decide whether errors are cacheable
Caching a successful expensive response for two minutes may be useful. Caching a temporary upstream 500 for two minutes may turn a brief outage into an artificially prolonged outage.
Possible policy:
- cache stable
200responses normally - optionally cache genuine
404responses for a short period when repeated misses are expensive - avoid caching transient
5xxfailures unless you intentionally implement stale-on-error behavior - use shorter TTLs for uncertain or rapidly changing results
The important point is to distinguish a business result from a dependency failure.
Invalidation should follow the owning source of truth
If a PUT /products/42 changes data that also backs GET /products/42, the write path should invalidate the relevant application cache after the durable write succeeds.
For Redis:
update primary database
DEL api-cache:product:42
If a CDN also caches that response, you need either:
- a sufficiently short shared TTL
- a provider-supported purge/invalidation mechanism
- versioned URLs or representations where appropriate
Do not assume deleting Redis automatically clears browser and CDN copies. Cache layers are independent unless you explicitly coordinate them.
Avoid synchronized expiration and cache stampedes
When a popular API cache entry expires, hundreds of requests can miss simultaneously and all call the same database or upstream service.
Mitigations include:
- request coalescing / single-flight rebuilding
- a short distributed lock for hot entries
- TTL jitter so many keys do not expire at once
- proactive refresh for a small number of extremely hot keys
- stale-while-revalidate behavior where the chosen cache layer supports it and stale data is acceptable
The mechanism matters less than defining the failure mode before peak traffic finds it for you.
stale-while-revalidate can hide refresh latency
RFC 5861 defines the stale-while-revalidate extension used by many HTTP caching systems. It permits a cache, within the declared window, to serve a stale representation while revalidating it asynchronously.
A representative header can look like:
Cache-Control: public, max-age=60, stale-while-revalidate=30
For appropriate public endpoints, this can keep latency stable while the cache refreshes.
Do not use stale serving for data whose freshness is security-critical, transactional, or legally significant merely to improve response time.
Authenticated APIs need a stricter default
For endpoints returning private user data, a safer starting point is often:
Cache-Control: private, no-cache
or, when storage must be prohibited:
Cache-Control: no-store
Then decide separately whether an internal Redis cache can safely cache some lower-level shared data used to construct the personalized response.
For example, a user's order page may not belong in a shared CDN cache, while the public product metadata referenced by those orders can still be cached aggressively.
This layered design usually provides better reuse than caching complete personalized responses.
Do not confuse HTTP Age with your Redis TTL
Shared HTTP caches can send an Age header indicating how long the stored response has resided in a cache according to HTTP age calculations.
Redis TTL tells your application how long a Redis key has before expiry.
They are independent clocks in different cache layers. Debugging becomes much easier when observability identifies which layer produced the hit.
Useful response diagnostics in controlled environments include headers such as:
X-Cache: HIT
X-Cache-Layer: edge
Use provider-native cache-status headers where available rather than inventing a permanent public header contract unless you actually need one.
Measure each cache layer separately
Track at least:
- browser/CDN/gateway hit rate where visible
- Redis hit and miss rate
- origin request volume
- upstream API request volume
- response freshness / age
- cache fill latency
- purge/invalidation failures
- Redis errors and fallbacks
- stampede coordination waits
- bytes transferred for revalidated versus full responses
A high Redis hit rate is not very meaningful if the CDN could have served the request without reaching your application at all.
Likewise, a high CDN hit rate does not prove personalized endpoints are keyed safely.
A practical decision framework
Use browser or private HTTP caching when
- one client can safely reuse its own response
- protocol-native validation with ETags is valuable
- the response is personalized but safe to keep in a private cache
Use a CDN/shared HTTP cache when
- the response is public or deliberately shareable
- many clients request the same representation
- origin offload matters
Use Redis/application response caching when
- producing the response is expensive inside the backend
- multiple app instances need a shared cached result
- the upstream is slow, costly, or rate-limited
- application-controlled invalidation is required
Use no cache when
- correctness requires the latest committed state
- the response is security-sensitive and storage adds unacceptable risk
- request variation makes safe keys impractical
- cache invalidation is harder than simply serving the origin query
Production checklist
Before enabling caching on an API route, answer:
- Is the response public, private, or non-cacheable?
- Which cache layers are allowed to store it?
- What request fields change the representation?
- What is the acceptable freshness window?
- Can
ETagorLast-Modifiedrevalidation reduce transfers? - If Redis is used, what is the deterministic key?
- What invalidates the entry after a write?
- What happens when Redis, the CDN, or the upstream is unavailable?
- Can stale data be served, and for how long?
- How are stampedes controlled?
- How will we prove that one user's response can never be served to another user?
Good API caching is not "cache everything." It is a freshness and reuse contract shared by the client, intermediaries, application, and source of truth.
References
BUILDING A SAAS PRODUCT?
Explore Lofingo’s end-to-end SaaS platforms, multi-tenant cloud architecture, and high-concurrency backend engineering.
EXPLORE SAAS SERVICES →