Introduction#
A CDN (Content Delivery Network) caches content at edge nodes geographically close to users, reducing latency and offloading traffic from origin servers. Used correctly, a CDN can serve 90%+ of traffic without hitting your servers. Used incorrectly, it adds complexity and cost without benefit.
How Edge Caching Works#
1
2
3
4
5
6
7
8
9
10
11
User in Tokyo CDN Edge (Tokyo) Origin (us-east-1)
| | |
|--GET /api/products------->| |
| |-- Cache MISS |
| |--GET /api/products--------->|
| |<--200 products + headers----|
|<--200 products------------|-- Cached for TTL |
| | |
|--GET /api/products------->| |
|<--200 products (HIT)------|-- Served from edge |
| (from Tokyo, ~2ms) | (no origin hit) |
Cache-Control Headers#
The CDN respects Cache-Control headers from your origin to decide what to cache and for how long.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# FastAPI: setting cache headers
from fastapi import Response
@app.get("/api/products")
async def get_products(response: Response):
response.headers["Cache-Control"] = "public, max-age=300, s-maxage=3600"
# public: CDN may cache this
# max-age=300: browser caches for 5 minutes
# s-maxage=3600: CDN caches for 1 hour (overrides max-age for CDN)
return products
@app.get("/api/user/profile")
async def get_profile(response: Response, user: User = Depends(get_current_user)):
response.headers["Cache-Control"] = "private, max-age=60"
# private: only the browser may cache — CDN must not cache
return user.profile
@app.get("/api/prices")
async def get_prices(response: Response):
response.headers["Cache-Control"] = "no-store"
# no-store: nothing may cache — always fresh from origin
return current_prices
| Directive | Meaning |
|---|---|
public |
CDN and browser may cache |
private |
Only browser may cache |
no-cache |
Must revalidate with origin before using |
no-store |
Nothing may cache |
max-age=N |
Browser TTL in seconds |
s-maxage=N |
CDN TTL in seconds (overrides max-age for CDN) |
stale-while-revalidate=N |
Serve stale while refreshing in background |
Cache Keys#
The cache key determines what constitutes a “unique” request. By default, CDNs key on URL + Host. Add headers or query parameters with care.
1
2
3
4
5
6
7
8
9
# Cloudflare: cache key configuration (in Page Rules or Cache Rules)
Cache-Key: ${host}${uri} # default
# Problem: if your origin varies response by Accept-Language,
# you need to include it in the cache key
# Otherwise German users get English cached content
# Vary header: tells CDN to include these headers in the cache key
Vary: Accept-Language, Accept-Encoding
1
2
3
4
5
6
7
8
9
10
@app.get("/api/products")
async def get_products(
request: Request,
response: Response,
language: str = "en"
):
response.headers["Cache-Control"] = "public, s-maxage=3600"
response.headers["Vary"] = "Accept-Language"
products = await get_localized_products(language)
return products
Avoid Vary: Cookie — it effectively disables CDN caching for authenticated users.
Cache Invalidation#
TTL-based expiry works but means stale content for up to TTL seconds after updates. Purging allows immediate invalidation.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Cloudflare: purge specific URLs after update
import httpx
CLOUDFLARE_API = "https://api.cloudflare.com/client/v4"
CF_ZONE_ID = "your-zone-id"
CF_TOKEN = "your-api-token"
async def purge_urls(urls: list[str]) -> None:
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{CLOUDFLARE_API}/zones/{CF_ZONE_ID}/purge_cache",
headers={"Authorization": f"Bearer {CF_TOKEN}"},
json={"files": urls}
)
resp.raise_for_status()
# After updating products, purge related CDN entries
async def update_product(product_id: int, data: dict):
await db.update_product(product_id, data)
await purge_urls([
f"https://example.com/api/products",
f"https://example.com/api/products/{product_id}",
])
For mass invalidation, use cache tags (supported by Cloudflare Enterprise, Fastly):
1
2
3
4
5
6
7
8
9
10
11
12
13
# Tag responses with a cache tag
@app.get("/api/products")
async def get_products(response: Response):
response.headers["Cache-Tag"] = "products" # Cloudflare/Fastly
response.headers["Surrogate-Key"] = "products" # Fastly
return products
# Purge everything tagged "products"
async def purge_by_tag(tag: str):
await client.post(
f"{CF_API}/zones/{ZONE}/purge_cache",
json={"tags": [tag]}
)
What to Cache vs What Not To#
Cache (CDN-appropriate):
- Product catalogs, public listings
- Static API responses that don’t vary by user
- Images, CSS, JS, fonts (long TTLs)
- Documentation pages
Do not cache:
- User-specific data (cart, profile, recommendations)
- Authentication endpoints
- Write operations (POST, PUT, DELETE)
- Real-time data (stock prices, live scores)
- Responses with sensitive data
Cache Bypass for Authenticated Requests#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@app.get("/api/products")
async def get_products(
request: Request,
response: Response,
authorization: str | None = Header(None),
):
if authorization:
# Authenticated: return personalized, non-cacheable response
response.headers["Cache-Control"] = "private, no-store"
return await get_personalized_products(authorization)
else:
# Anonymous: cacheable
response.headers["Cache-Control"] = "public, s-maxage=300"
return await get_public_products()
Monitoring CDN Performance#
1
2
3
4
5
6
7
8
9
10
11
12
# Cache hit rate (should be > 80% for static content)
# Cloudflare: Analytics → Caching
# Response headers tell you if CDN served the response
curl -I https://example.com/api/products
# CF-Cache-Status: HIT ← served from Cloudflare edge
# CF-Cache-Status: MISS ← fetched from origin
# CF-Cache-Status: EXPIRED← served stale, revalidating
# CF-Cache-Status: BYPASS ← caching bypassed (no-store, POST, etc.)
# X-Cache header (older CDNs)
# X-Cache: HIT from edge.node.fastly.net
Conclusion#
CDN caching reduces latency and origin load for public content. Set s-maxage for CDN TTL and max-age for browser TTL separately. Use Vary headers only when necessary — they fragment the cache. Implement purge-on-update for content that must not remain stale. Never cache user-specific or sensitive responses — use private, no-store for these.