API Caching: Redis, CDN, and Response Caching
Caching is the single most effective performance optimization for APIs. A well-cached API reduces database load, lowers latency, and handles traffic spikes without scaling. Every layer of the stack — from the CDN at the edge to the in-memory cache beside the database — can be tuned for caching.
Caching Layers
API responses can be cached at multiple levels:
| Layer | Location | Latency | Storage | Invalidation |
|---|---|---|---|---|
| CDN | Edge nodes | 1–10 ms | Distributed | TTL + purge API |
| Reverse proxy | Nginx/HAProxy | <1 ms | RAM on proxy | TTL + config reload |
| Application cache | Redis/Memcached | <1 ms | Remote RAM | TTL + explicit delete |
| In-process cache | Application heap | <0.1 ms | Local RAM | TTL + eviction policy |
| Database cache | Buffer pool | <10 ms | RAM + disk | Built-in |
Each layer has trade-offs. CDN caches are great for public, cacheable responses but cannot serve authenticated content. In-process caches are blazing fast but do not scale across instances. Redis is the sweet spot for most APIs: fast, shared across all application instances, and supports rich invalidation patterns.
Cache Headers
HTTP cache headers control how responses are cached at the CDN, proxy, and browser levels:
Cache-Control: public, max-age=3600, s-maxage=3600
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Last-Modified: Tue, 15 Nov 2024 12:45:26 GMT
Expires: Tue, 15 Nov 2024 13:45:26 GMT
Vary: Accept-Encoding- Cache-Control — primary directive;
publicallows any cache,privaterestricts to browser cache,no-cacherequires revalidation - max-age — seconds the response is considered fresh;
s-maxageoverrides for shared (proxy/CDN) caches - ETag — opaque hash of the response content; clients send
If-None-Matchfor validation - Last-Modified — timestamp of last change; clients send
If-Modified-Sincefor validation - Vary — which request headers affect the cached representation (typically
Accept-EncodingorAccept-Language)
Invalidating CDN caches is more expensive than application cache invalidation. A common strategy is to use short max-age (5–60 seconds) on the CDN and rely on the application cache for longer-lived storage with explicit invalidation.
ETags and Conditional Requests
ETags enable lightweight validation caching. When a client sends If-None-Match with the previous ETag, the server can return 304 Not Modified with an empty body:
from hashlib import sha256
def get_user(request, user_id):
user = get_user_from_db(user_id)
etag = sha256(json.dumps(user).encode()).hexdigest()
if request.headers.get("If-None-Match") == etag:
return Response(status=304)
return Response(json=user, headers={"ETag": etag})Strong ETags (byte-exact) are preferred for API responses. Weak ETags (W/"...") are acceptable for large responses where byte equality is too expensive to compute. The 304 Not Modified response saves bandwidth — the client already has the content in its local cache — but the server still computes the ETag, so the CPU savings depend on how expensive the ETag computation is versus full response generation.
For expensive API calls, compute the ETag from a lightweight checksum (like a hash of the database row’s updated_at timestamp) instead of hashing the entire response payload.
Redis Caching Patterns
Redis is the most common application-side cache for APIs. Several patterns address different needs:
Read-Through Cache
def get_product(product_id):
cache_key = f"product:{product_id}"
cached = redis.get(cache_key)
if cached:
return json.loads(cached)
product = db.query(Product).get(product_id)
redis.setex(cache_key, 300, json.dumps(product.to_dict()))
return productCache-Aside (Lazy Loading)
The application checks the cache first, and on a miss, loads from the database and populates the cache. This is the most common pattern. The TTL is the safety net — even if the application fails to invalidate explicitly, the cache will eventually expire.
Write-Through Cache
def update_product(product_id, data):
product = db.query(Product).get(product_id)
product.update(data)
db.commit()
cache_key = f"product:{product_id}"
redis.setex(cache_key, 300, json.dumps(product.to_dict()))
return productCache Invalidation Strategies
| Strategy | Mechanism | Pros | Cons |
|---|---|---|---|
| TTL | Time-based expiry | Simple, eventually consistent | Stale data until expiry |
| Write-through | Update cache on write | Always fresh | Higher write latency |
| Cache eviction | Delete key on write | Low write overhead | Cache miss on next read |
| Pub/Sub | Broadcast invalidate across instances | Consistent across workers | Infrastructure complexity |
A common production pattern is to delete the cache key on writes (cache eviction) and rely on TTL as a fallback. This avoids the write amplification of write-through while keeping reads fresh on the next request. For high-traffic endpoints, a write-through pattern may be preferable to avoid stampedes (multiple concurrent requests all triggering a cache miss and hitting the database simultaneously).
CDN Caching
CDNs cache at the HTTP level and respect Cache-Control headers. For API responses:
- Public endpoints (product listings, blog posts): cache at the CDN with
s-maxage=60— the CDN serves most requests, the application serves only misses - Authenticated endpoints: set
Cache-Control: private— CDNs must not cache responses with user-specific content - Soft purges: update cached content without invalidating the CDN URL (useful for correcting stale data)
- Stale-while-revalidate: serve stale content while refreshing in the background —
Cache-Control: stale-while-revalidate=30means serve stale for up to 30 seconds while fetching fresh
Cache-Control: public, max-age=10, stale-while-revalidate=30This pattern is especially valuable for APIs that cannot tolerate cache misses: the CDN serves stale data for up to 30 seconds while it fetches a fresh response from the origin, so the client never sees a cache miss.
Cache Stampede Prevention
A cache stampede (thundering herd) happens when a popular cache key expires and thousands of requests hit the database simultaneously before any of them writes the new cache value.
Solutions:
- Locking: Only one request computes the value; others wait (Redis SETNX with lock key, or a mutex in the application)
- Early recalculation: Refresh the cache before it expires if the value is near TTL and the key is hot
- Probabilistic early expiration: Randomly refresh early based on remaining TTL — Netflix’s approach
- Permanent cache + background refresh: Cache never expires; a background job refreshes it periodically
import random
def get_hot_product(product_id):
cache_key = f"product:{product_id}"
cached = redis.get(cache_key)
if cached:
ttl = redis.ttl(cache_key)
product = json.loads(cached)
# Probabilistic early recomputation — 10% chance when TTL < 60s
if ttl < 60 and random.random() < 0.1:
spawn_background_refresh(product_id, cache_key)
return product
return recompute_product(product_id, cache_key)Cache Granularity
| Granularity | Example | Pros | Cons |
|---|---|---|---|
| Full response | Entire /products | Simple, fast | Stale across users |
| Resource | Single product object | Targeted invalidation | Many keys |
| Database query | SQL result set | Works with any data | Hard to invalidate |
| Fragment | HTML snippet | Fine-grained | Complexity |
Most APIs should start with resource-level caching (cache per object) and add full-response caching for endpoints with simple invalidation rules. Avoid caching entire collections when individual items change independently — you will either serve stale collections or invalidate the entire collection cache on every item update.
Redis Memory Management
Redis stores data in memory, so cache size directly impacts infrastructure cost. Set a maximum memory limit (maxmemory) and configure an eviction policy for when that limit is reached. The allkeys-lru policy evicts the least recently used keys, which works well for most API caches where older data is less likely to be requested. For time-series data or event caches, volatile-ttl evicts keys with the shortest remaining TTL first. Monitor Redis memory usage and set alerts at 70% and 85% capacity. If your cache consistently exceeds 80% of maxmemory, scale up the Redis instance or add a secondary cache layer. Use redis-cli --bigkeys periodically to identify oversized keys that may indicate caching granularity problems.
FAQ
What is the difference between a cache hit and a cache miss? A cache hit occurs when the requested data is found in the cache, allowing the API to return it immediately without querying the database. A cache miss occurs when the data is not in the cache, requiring a full database query and then populating the cache for future requests.
How long should cache TTL be? It depends on your data’s freshness requirements. For real-time data, TTL of 1-5 seconds is appropriate. For product catalogs, 5-60 minutes. For static reference data, hours or days. Start with short TTLs and increase them as you validate cache correctness.
What is the best caching strategy for authenticated APIs? Set Cache-Control: private and use ETags for validation. The browser will cache responses per-user. For API-to-API communication, implement application-level caching with user-specific cache keys.
How do I handle caching with paginated results? Cache each page independently with its own key. Include the page number and page size in the cache key. When any item on a page changes, invalidate only that page. Consider cursor-based pagination for more stable cache keys.
Can I cache POST requests? HTTP allows caching POST responses, but most CDNs and proxies do not implement it. For POST endpoints, implement application-level caching based on the request body hash as part of the cache key. Ensure idempotency before caching POST responses.
How do I handle cache warm-up after a deployment? Implement a cache warm-up script that runs as part of your deployment pipeline. Identify the most frequently accessed keys from production traffic patterns (e.g., top 1000 product IDs) and pre-populate the cache before routing traffic to the new instances. Use a staggered deployment strategy — warm caches on a subset of instances before routing traffic, then warm caches on remaining instances. For critical endpoints, consider maintaining a hot standby cache that persists across deployments using Redis persistence (RDB snapshots or AOF logs) so the cache is not cold after a restart.
Internal Links
REST API Design Guide — API Security Best Practices — API Monitoring Tools