Cache-Friendly API Design: REST API Patterns Optimized for Caching
In this tutorial, you will learn about Cache. We cover key concepts, practical examples, and best practices to help you master this topic.
Cache-friendly API design structures REST endpoints, response headers, and URL patterns to maximize cacheability, using techniques like resource-oriented URLs, ETags for conditional requests, and Cache-Control directives that enable intermediate caches to serve responses efficiently.
flowchart LR
Client[Client] -->|GET /api/users/42| CDN[CDN Cache]
CDN -->|Cache-Control: public, max-age=3600| Cache{Cached?}
Cache -->|Hit + ETag match| 304[304 Not Modified]
Cache -->|Miss| API[API Server]
API -->|ETag: abc123| CDN
304 --> Client
API -->|200 + Data| Client
What You'll Learn
- Resource-oriented URL design for cache-friendly APIs
- ETags and conditional requests for validation Caching
- Cache-Control headers for controlling cache behavior
- Pagination strategies that preserve cacheability
Why It Matters
A cache-friendly API can serve 90%+ of requests from CDN or edge caches, reducing origin server load by 10x and cutting response times from 100ms to 5ms for cached responses. Am API design that ignores cacheability burns infrastructure budget and degrades user experience.
Real-World Use
DodaTech's REST API for file metadata uses resource-oriented URLs (/api/files/{id}), strong ETags based on file content hash, and Cache-Control: public, max-age=3600. The CDN serves 94% of requests without contacting the origin. Conditional requests with If-None-Match reduce the remaining 6% to 304 responses for 80% of them.
Resource-Oriented URLs
Design URLs that represent cacheable resources:
import redis
import json
import hashlib
r = redis.Redis(decode_responses=True)
class CacheFriendlyAPI:
def __init__(self, redis_client):
self.r = redis_client
def generate_etag(self, data):
"""Generate a strong ETag from response data."""
return hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()[:16]
def get_resource(self, resource_type, resource_id, if_none_match=None):
"""Get a resource with ETag-based conditional request support."""
cache_key = f"api:{resource_type}:{resource_id}"
cached = self.r.get(cache_key)
if cached:
data = json.loads(cached)
current_etag = data.get("_etag")
if if_none_match and if_none_match == current_etag:
return {"status": 304, "etag": current_etag, "data": None}
return {"status": 200, "etag": current_etag, "data": data}
return {"status": 404, "data": None}
def create_or_update(self, resource_type, resource_id, data, ttl=3600):
"""Create or update a resource with ETag."""
etag = self.generate_etag(data)
data["_etag"] = etag
data["_type"] = resource_type
data["_id"] = resource_id
cache_key = f"api:{resource_type}:{resource_id}"
self.r.setex(cache_key, ttl, json.dumps(data))
return {
"status": 200,
"etag": etag,
"location": f"/api/{resource_type}/{resource_id}",
}
def invalidate_resource(self, resource_type, resource_id):
"""Invalidate a cached resource."""
cache_key = f"api:{resource_type}:{resource_id}"
self.r.delete(cache_key)
return {"invalidated": True, "resource": f"{resource_type}/{resource_id}"}
api = CacheFriendlyAPI(r)
api.create_or_update("users", 42, {
"name": "Alice", "email": "alice@example.com", "role": "admin"
})
result = api.get_resource("users", 42)
print(f"First request: status={result['status']}, etag={result['etag']}")
etag = result["etag"]
result = api.get_resource("users", 42, if_none_match=etag)
print(f"Conditional request (same ETag): status={result['status']}")
result = api.get_resource("users", 42, if_none_match="old_etag")
print(f"Conditional request (stale ETag): status={result['status']}")
api.invalidate_resource("users", 42)
result = api.get_resource("users", 42)
print(f"After invalidation: status={result['status']}")
Expected output:
First request: status=200, etag=a1b2c3d4e5f6g7h8
Conditional request (same ETag): status=304
Conditional request (stale ETag): status=200
After invalidation: status=404
Pagination for Cacheable Collections
Design pagination that preserves cacheability:
import redis
import json
r = redis.Redis(decode_responses=True)
class CacheablePagination:
def __init__(self, redis_client):
self.r = redis_client
def build_collection_key(self, resource_type, filters):
"""Build a cache key from resource type and filters."""
filter_parts = []
for key, value in sorted(filters.items()):
filter_parts.append(f"{key}={value}")
filter_string = "&".join(filter_parts)
return f"api:{resource_type}:collection:{filter_string}"
def get_page(self, resource_type, filters, page, page_size=20):
"""Get a page of results with cache check."""
collection_key = self.build_collection_key(resource_type, filters)
page_key = f"{collection_key}:page:{page}:size:{page_size}"
cached = self.r.get(page_key)
if cached:
return {"source": "cache", "data": json.loads(cached), "page": page}
return {"source": "miss", "data": None, "page": page}
def cache_page(self, resource_type, filters, page, page_size, data, ttl=3600):
"""Cache a page of results."""
collection_key = self.build_collection_key(resource_type, filters)
page_key = f"{collection_key}:page:{page}:size:{page_size}"
total_count = data.get("total", 0)
total_pages = (total_count + page_size - 1) // page_size
cache_entry = {
"items": data.get("items", []),
"page": page,
"page_size": page_size,
"total": total_count,
"total_pages": total_pages,
"next": f"?page={page + 1}&size={page_size}" if page < total_pages else None,
"prev": f"?page={page - 1}&size={page_size}" if page > 1 else None,
}
self.r.setex(page_key, ttl, json.dumps(cache_entry))
return cache_entry
def invalidate_collection(self, resource_type, filters=None):
"""Invalidate all pages in a collection."""
import re
if filters:
pattern = self.build_collection_key(resource_type, filters) + ":*"
else:
pattern = f"api:{resource_type}:collection:*"
keys = self.r.keys(pattern)
if keys:
self.r.delete(*keys)
return {"invalidated": len(keys) if keys else 0}
pagination = CacheablePagination(r)
filters = {"status": "active", "sort": "name"}
items = [{"id": i, "name": f"Item {i}"} for i in range(1, 101)]
page_data = pagination.cache_page("products", filters, 1, 20, {
"items": items[:20], "total": 100
})
print(f"Cached page 1: {page_data['total']} total, next={page_data['next'] is not None}")
result = pagination.get_page("products", filters, 1, 20)
print(f"Read page 1: source={result['source']}")
result = pagination.get_page("products", filters, 2, 20)
print(f"Read page 2: source={result['source']}")
result = pagination.invalidate_collection("products", filters)
print(f"Invalidated: {result['invalidated']} cache entries")
Expected output:
Cached page 1: 100 total, next=True
Read page 1: source=cache
Read page 2: source=miss
Invalidated: 1 cache entries
Cache Control Headers
Manage cache behavior with Cache-Control directives:
import redis
import json
r = redis.Redis(decode_responses=True)
class CacheControlManager:
def __init__(self, redis_client):
self.r = redis_client
def cache_policy(self, resource_type):
"""Define cache policies for different resource types."""
policies = {
"static_assets": {
"cache_control": "public, max-age=31536000, immutable",
"description": "One year, never revalidate",
"cdn_cacheable": True,
},
"api_collections": {
"cache_control": "public, max-age=300, stale-while-revalidate=3600",
"description": "5 min fresh, 1 hour stale with background refresh",
"cdn_cacheable": True,
},
"user_profiles": {
"cache_control": "private, max-age=60",
"description": "Private (no CDN), 1 minute",
"cdn_cacheable": False,
},
"search_results": {
"cache_control": "public, max-age=120, stale-if-error=86400",
"description": "2 min fresh, 24 hour stale on error",
"cdn_cacheable": True,
},
"auth_endpoints": {
"cache_control": "no-store",
"description": "Never cache",
"cdn_cacheable": False,
},
}
return policies.get(resource_type, policies["api_collections"])
def apply_policy(self, resource_type, response_data):
"""Apply cache policy to a response."""
policy = self.cache_policy(resource_type)
return {
"data": response_data,
"headers": {
"Cache-Control": policy["cache_control"],
"X-Cache-Policy": resource_type,
},
"policy": policy,
}
def recommend_policy(self, resource_characteristics):
"""Recommend a cache policy based on resource characteristics."""
freshness = resource_characteristics.get("freshness_requirement", "medium")
cardinality = resource_characteristics.get("user_specific", "public")
change_frequency = resource_characteristics.get("change_frequency", "hourly")
if cardinality == "private":
return "private, max-age=60"
elif change_frequency == "static":
return "public, max-age=31536000, immutable"
elif change_frequency == "rarely":
return "public, max-age=86400"
elif change_frequency == "hourly":
return "public, max-age=3600, stale-while-revalidate=3600"
else:
return "public, max-age=300"
cache_control = CacheControlManager(r)
resources = ["static_assets", "api_collections", "user_profiles", "auth_endpoints"]
for resource in resources:
policy = cache_control.cache_policy(resource)
print(f"{resource:20s} -> {policy['cache_control']:45s} ({policy['description']})")
rec = cache_control.recommend_policy({
"freshness_requirement": "medium",
"user_specific": "public",
"change_frequency": "hourly"
})
print(f"\nRecommended policy for hourly API: {rec}")
Expected output:
static_assets -> public, max-age=31536000, immutable (One year, never revalidate)
api_collections -> public, max-age=300, stale-while-revalidate=3600 (5 min fresh, 1 hour stale with background refresh)
user_profiles -> private, max-age=60 (Private (no CDN), 1 minute)
auth_endpoints -> no-store (Never cache)
Recommended policy for hourly API: public, max-age=3600, stale-while-revalidate=3600
Common Mistakes
- Using query parameters for everything — /api/users?sort=name&filter=active creates many cache variants. Use path-based URLs for resources: /api/users/active/sort-by-name.
- Not setting Cache-Control headers — without explicit cache policy, intermediate caches apply default heuristics or cache nothing. Always set Cache-Control on every response.
- Using POST for cacheable read operations — POST responses are not cached by browsers or CDNs. Use GET for read operations to benefit from caching.
- Including user-specific data in cacheable responses — if the response varies per user, set Cache-Control: private or no-store. Mixing public and private data in the same response hurts cacheability.
- Returning 200 instead of 304 for unchanged resources — 304 responses are smaller and faster. Always support If-None-Match and If-Modified-Since conditional requests.
Practice Questions
- Why are resource-oriented URLs more cache-friendly than query-parameter-heavy URLs?
- How do ETags enable conditional requests and reduce bandwidth?
- What is the difference between Cache-Control: public and Cache-Control: private?
- How does stale-while-revalidate improve perceived performance?
- Why is it important to use GET instead of POST for read operations?
Challenge
Design a cache-friendly REST API for a blog platform. Resources: posts (change hourly, public), comments (change minutely, public), user profiles (change rarely, private), and search results (change per query, public). Propose URL structure, Cache-Control headers, ETag generation Strategy (content hash vs last-modified), and pagination approach. Support conditional requests for all resources.
FAQ
Mini Project
Build a cache-friendly REST API framework that: (1) auto-generates strong ETags from response content, (2) handles If-None-Match and If-Modified-Since headers, (3) applies Cache-Control policies based on resource type configuration, (4) supports cursor-based pagination with stable cache keys, and (5) provides a cache invalidation endpoint for purging specific resources or collections.
What's Next
Continue with Cache Security to learn about securing your cache layer, then explore Cache Rate Limiting for protecting your cache infrastructure.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro