Dynamic sites still benefit heavily from CDN caching when cacheability rules, cache keys, and invalidation flows are intentionally designed. The misconception that dynamic means uncacheable costs teams both performance and money. According to web performance research, CDN cache hit rates above 85% reduce origin load by 6x and cut p95 response times by 60-70% for end users. Even pages with personalized elements can leverage edge caching for the majority of their content.
---
Build a Cacheability Matrix
Not all content is equally cacheable. Before configuring a CDN, classify every response type in your application:
| Content Class | TTL Strategy | SWR | Cache Scope | Example |
| Static assets (JS, CSS, images) | 1 year | N/A (immutable) | Public | `/assets/main.a1b2c3.js` |
| Marketing pages | 5-15 minutes | 1 hour | Public | `/pricing`, `/features` |
| Public API responses | 30-120 seconds | 5 minutes | Public | `/api/catalog/products` |
| Shared dynamic content | 10-60 seconds | 2 minutes | Public | `/api/status`, `/api/plans` |
| Personalized content | No shared cache | N/A | Private | `/api/user/profile` |
| Authenticated mutations | No cache | N/A | Private | `POST /api/orders` |
This matrix becomes your source of truth for cache-control header configuration. Every backend endpoint should map to exactly one content class.
Cache-Control Header Configuration
The `Cache-Control` header is the contract between your origin and the CDN. Misconfigured headers are the root cause of most caching problems.
<h1>FastAPI middleware for content-class-based cache headers</h1>
from fastapi import Request, Response
from functools import wraps
CACHE_POLICIES = {
"static": "public, max-age=31536000, immutable",
"marketing": "public, max-age=600, stale-while-revalidate=3600",
"public_api": "public, max-age=60, stale-while-revalidate=300, stale-if-error=86400",
"shared_dynamic": "public, max-age=30, stale-while-revalidate=120",
"private": "private, no-store",
"no_cache": "no-store, no-cache",
}
def cache_policy(policy_name: str):
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
response = await func(*args, **kwargs)
response.headers["Cache-Control"] = CACHE_POLICIES[policy_name]
return response
return wrapper
return decorator
@app.get("/api/catalog/products")
@cache_policy("public_api")
async def list_products():
return await db.fetch_products()
The stale-while-revalidate Pattern
`stale-while-revalidate` (SWR) is the most important directive for dynamic content. It tells the CDN: serve stale content immediately while fetching a fresh copy in the background.
Cache-Control: public, max-age=60, stale-while-revalidate=300, stale-if-error=86400
This means:
- For the first 60 seconds: serve from cache directly
- From 60 to 360 seconds: serve stale content, revalidate in background
- If origin returns 5xx: serve stale content for up to 24 hours
The user always gets a fast response. Origin load stays low. Content freshness stays within acceptable bounds.
Cache Key Design
The cache key determines when two requests are considered the same. A poorly designed cache key either reduces hit rates (too many unique keys) or serves wrong content (too few unique keys).
Rules for Cache Key Design
<h1>Nginx: strip tracking parameters from cache key</h1>
set $clean_uri $uri;
<h1>Remove utm_*, fbclid, gclid, ref from query string</h1>
if ($args ~* "^(.*)(?:utm_[a-z]+
fbclid gclid
ref)=[^&]*(.*)" ) {
set $args $1$2;
}
<h1>Normalize to lowercase for cache key consistency</h1>
proxy_cache_key "$scheme$host$clean_uri$is_args$args";
Vary Header Usage
<h1>Correct: only vary on dimensions that change output</h1>
Vary: Accept-Encoding, Accept-Language
<h1>Dangerous: Cookie in Vary header fragments cache</h1>
<h1>Vary: Cookie # Avoid this - every unique cookie creates a cache miss</h1>
Never include `Cookie` in the `Vary` header for public content. If content changes based on authentication state, use separate endpoints or edge-side includes instead.
Purge and Invalidation Strategy
The best cache invalidation is no invalidation. Design for TTL-based expiry wherever possible. When you must purge, use surgical invalidation rather than global flushes.
Tag-Based Invalidation
<h1>Add surrogate keys to responses for targeted purging</h1>
@app.get("/api/products/{product_id}")
async def get_product(product_id: int, response: Response):
product = await db.fetch_product(product_id)
response.headers["Surrogate-Key"] = f"product-{product_id} products catalog"
response.headers["Cache-Control"] = "public, max-age=300"
return product
<h1>When product is updated, purge by tag</h1>
async def on_product_update(product_id: int):
await cdn_client.purge_tag(f"product-{product_id}")
# Also purge listing pages
await cdn_client.purge_tag("catalog")
Automated Purge from Deploy Pipeline
<h1>GitHub Actions: purge CDN cache on deploy</h1>
steps:
- name: Deploy to production
run: ./deploy.sh
- name: Purge CDN cache for updated paths
run: |
curl -X POST "$CDN_API/purge" \
-H "Authorization: Bearer $CDN_TOKEN" \
-d '{"tags": ["marketing-pages", "pricing"]}'
- name: Warm critical paths
run: |
for url in / /pricing /features /docs; do
curl -s -o /dev/null "https://example.com${url}"
done
Personalization Without Cache Busting
Personalized content is the biggest challenge for CDN caching. Several patterns handle this without sacrificing cache hit rates:
Pattern 1: Client-Side Personalization
Serve a cached shell page from CDN, then hydrate personalized elements via API calls from the browser. The page shell gets a 90%+ cache hit rate, and personalization loads asynchronously.
Pattern 2: Edge-Side Includes (ESI)
Some CDNs support ESI, which lets you cache the page frame and inject personalized fragments at the edge:
<!-- Cached page shell -->
<div class="header">
<esi:include src="/api/user/greeting" />
</div>
<div class="content">
<!-- This section is fully cached -->
<h1>Product Catalog</h1>
...
</div>
Pattern 3: Segmented Caching
Cache a small number of response variants based on user segment (logged-in vs anonymous, region, plan tier) rather than individual identity:
<h1>Segment users into a small number of cache groups</h1>
def get_cache_segment(request: Request) -> str:
if not request.user:
return "anonymous"
if request.user.plan == "enterprise":
return "enterprise"
return "authenticated"
<h1>Set segment header for CDN to use as cache key</h1>
response.headers["X-Cache-Segment"] = get_cache_segment(request)
Review infrastructure tradeoffs on the Cloud Computing page, budget implications on Pricing, and rollout support via Contact.
Monitoring CDN Cache Performance
| Metric | Healthy Range | Action If Out of Range |
| Overall cache hit ratio | > 85% | Audit cache-control headers |
| Static asset hit ratio | > 98% | Check immutable + content hash |
| Origin request rate | Flat or declining | Investigate cache misses |
| Purge frequency | < 50/day | Reduce manual purges, use TTL |
| Stale-serve rate | < 5% | Origin may be unreliable |
| p95 TTFB (cache hit) | < 30ms | CDN edge performance issue |
| p95 TTFB (cache miss) | < 500ms | Origin performance issue |
Frequently Asked Questions
Can I cache API responses at the CDN edge?
Yes, for public API responses that do not vary by user identity. Product catalogs, pricing data, feature flags, and status endpoints are excellent candidates. Use short TTLs (30-120 seconds) with `stale-while-revalidate` to keep content fresh while maintaining high hit rates.
How do I prevent caching sensitive user data?
Set `Cache-Control: private, no-store` on all endpoints that return user-specific data. Never rely on the CDN to figure this out automatically. Audit your cache headers monthly, and use a test that verifies no authenticated endpoint returns a `public` cache-control directive.
What is the difference between purge and invalidation?
Purge removes content from the cache immediately. Invalidation marks content as stale, causing the CDN to revalidate on the next request. Invalidation is generally safer because it falls back to stale content if the origin is unhealthy. Use purge only when serving stale content is unacceptable (security patches, legal compliance).
How do I handle A/B tests with CDN caching?
Assign the A/B variant at the edge using a cookie or header, then include that variant identifier in the cache key. This gives you 2-3 cached variants per page instead of fragmenting the cache by individual user. Most CDN providers support this via edge workers or transform rules.
Should I warm the cache after a deploy?
Yes, for your highest-traffic pages. After purging the CDN cache during deployment, make requests to your top 20-50 URLs from multiple edge regions. This prevents a thundering herd of cache misses hitting your origin simultaneously when real users arrive.
Final Takeaway
Great CDN caching for dynamic applications is policy-driven and observable, not accidental. Build a cacheability matrix, configure cache-control headers explicitly, design cache keys carefully, and monitor hit rates continuously. The investment pays off in lower origin costs, faster user experience, and better resilience during traffic spikes.