Caching in REST APIs — Complete Guide
In this tutorial, you'll learn about Caching in REST. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
REST caching leverages HTTP's built-in caching mechanisms: Cache-Control headers, ETags, Last-Modified dates, and conditional requests to reduce server load and latency.
What You'll Learn
By the end of this lesson, you will implement HTTP caching headers, use ETags for conditional requests, configure CDN caching, and understand cache invalidation strategies.
Why It Matters
HTTP caching is REST's biggest advantage over Graphql. With proper caching, REST can serve repeated requests from cache in milliseconds without hitting the server.
Real-World Use
Cloudflare CDN caches REST API responses for hours. A GET /posts request hitting the CDN cache returns in 5ms vs 200ms from the origin server.
REST Caching Flow
sequenceDiagram
Client->>CDN: GET /posts
CDN->>Origin: Cache MISS (first request)
Origin->>CDN: 200 + Cache-Control: public, max-age=3600
CDN->>Client: Response (cached)
Client->>CDN: GET /posts (same URL)
CDN->>Client: Cached response (5ms)
HTTP Cache Headers
# cache_headers.py
from datetime import datetime, timedelta
from typing import Dict, Optional
class CacheHeaderBuilder:
def __init__(self):
self.default_max_age = 3600
def public(self, max_age: Optional[int] = None) -> Dict[str, str]:
age = max_age or self.default_max_age
return {
"Cache-Control": f"public, max-age={age}",
"Expires": (datetime.utcnow() + timedelta(seconds=age)).strftime(
"%a, %d %b %Y %H:%M:%S GMT"
),
}
def private(self, max_age: Optional[int] = None) -> Dict[str, str]:
age = max_age or self.default_max_age
return {
"Cache-Control": f"private, max-age={age}",
}
def no_cache(self) -> Dict[str, str]:
return {
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
}
def with_etag(self, data: dict, max_age: int = 3600) -> Dict[str, str]:
etag = f"W/\"{hash(str(data))}\""
return {
"Cache-Control": f"public, max-age={max_age}",
"ETag": etag,
}
builder = CacheHeaderBuilder()
print(f"Public: {builder.public(3600)}")
print(f"Private: {builder.private(600)}")
print(f"NoCache: {builder.no_cache()}")
print(f"ETag: {builder.with_etag({'id': 1})}")
Expected output:
Public: {'Cache-Control': 'public, max-age=3600', 'Expires': '...'}
Private: {'Cache-Control': 'private, max-age=600'}
NoCache: {'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache'}
ETag: {'Cache-Control': 'public, max-age=3600', 'ETag': 'W/"<hash>"'}
Conditional Requests with ETag
# conditional_requests.py
from typing import Any, Dict, Optional
class ETagCache:
def __init__(self):
self.resources: Dict[str, Dict] = {}
self.etags: Dict[str, str] = {}
def put(self, path: str, data: Dict):
self.resources[path] = data
self.etags[path] = f"W/\"{abs(hash(str(data)))}\""
def get(self, path: str, if_none_match: Optional[str] = None) -> Dict:
if path not in self.resources:
return {"status": 404, "data": None}
current_etag = self.etags[path]
if if_none_match and if_none_match == current_etag:
return {"status": 304, "data": None}
return {
"status": 200,
"data": self.resources[path],
"etag": current_etag,
}
cache = ETagCache()
cache.put("/users/1", {"id": 1, "name": "Alice"})
etag = cache.get("/users/1")["etag"]
first = cache.get("/users/1")
second = cache.get("/users/1", if_none_match=etag)
print(f"First request: status={first['status']}, data={first['data'] is not None}")
print(f"Second request: status={second['status']}, data={second['data']}")
Expected output:
First request: status=200, data=True
Second request: status=304, data=None
CDN Caching Configuration
# cdn_caching.py
from typing import Dict, List, Optional
class CDNConfig:
def __init__(self):
self.rules: List[Dict] = []
def add_rule(self, path_pattern: str, ttl_seconds: int,
cache_methods: Optional[List[str]] = None):
self.rules.append({
"path": path_pattern,
"ttl": ttl_seconds,
"methods": cache_methods or ["GET"],
})
def should_cache(self, path: str, method: str) -> Optional[int]:
for rule in self.rules:
if path.startswith(rule["path"]) and method in rule["methods"]:
return rule["ttl"]
return None
def summary(self):
print(f"{'Path Pattern':20s} {'TTL':10s} {'Methods'}")
print("-" * 45)
for rule in self.rules:
ttl = f"{rule['ttl']}s" if rule['ttl'] < 3600 else f"{rule['ttl'] // 3600}h"
print(f"{rule['path']:20s} {ttl:10s} {rule['methods']}")
cdn = CDNConfig()
cdn.add_rule("/posts", 3600)
cdn.add_rule("/users", 600, ["GET", "HEAD"])
cdn.add_rule("/auth", 0)
path = "/posts/123"
ttl = cdn.should_cache(path, "GET")
print(f"Cache {path} for {ttl}s")
path2 = "/auth/login"
ttl2 = cdn.should_cache(path2, "GET")
print(f"Cache {path2}: {ttl2}s (not cached)")
Expected output:
Cache /posts/123 for 3600s
Cache /auth/login: None (not cached)
Common Mistakes
1. No Cache Headers
Sending responses without Cache-Control headers. Proxies and browsers may still cache with unpredictable behavior.
2. Over-Caching Private Data
Marking user-specific data as public lets CDNs cache it. Use private for user-specific responses.
3. No Cache Invalidation
Updating data without purging cached responses. Clients see stale data for the cache duration.
4. Caching POST Requests
POST responses should not be cached by default. Use POST only for mutations, GET for reads.
5. Ignoring Vary Header
Without Vary: Accept, cached JSON responses may be served to clients expecting XML.
Practice Questions
1. What HTTP header controls caching?
Cache-Control with directives like public, private, max-age, no-cache.
2. What is ETag used for?
Conditional requests. The client sends If-None-Match with the ETag, and the server returns 304 if unchanged.
3. What is the difference between public and private Cache-Control?
Public: any cache (CDN, proxy) can cache. Private: only the browser can cache.
4. How do you invalidate a cached response?
Use a new URL (versioned path), purge the CDN cache, or use short max-age with revalidation.
Challenge
Build a caching layer for a REST API that supports ETag conditional requests, Cache-Control headers, and a CDN purge endpoint for cache invalidation.
FAQ
Mini Project: REST Cache Layer
# rest_cache.py
from typing import Dict, Optional
from time import time
class RESTCache:
def __init__(self):
self.cache: Dict[str, Dict] = {}
self.ttls: Dict[str, float] = {}
def get(self, url: str) -> Optional[Dict]:
if url in self.cache and time() < self.ttls[url]:
return {**self.cache[url], "_from_cache": True}
return None
def set(self, url: str, data: Dict, ttl: int = 300):
self.cache[url] = data
self.ttls[url] = time() + ttl
def invalidate(self, url: str):
self.cache.pop(url, None)
self.ttls.pop(url, None)
cache = RESTCache()
cache.set("/users", [{"id": 1}], ttl=60)
print(f"Cached: {cache.get('/users') and 'YES'}")
cache.invalidate("/users")
print(f"After invalidate: {cache.get('/users') and 'YES' or 'NO'}")
Expected output:
Cached: YES
After invalidate: NO
What's Next
You understand REST caching. Next, explore GraphQL caching, then versioning in REST.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro