Complete Guide to CDN Caching Strategy
Design CDN caching for web applications and APIs. Covers edge caching, cache keys, cache headers, invalidation strategies, surrogate keys, and multi-CDN setups for global performance.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Introduction
A Content Delivery Network (CDN) caches your content at edge locations close to users. When a user in Tokyo requests a page served from a data center in Virginia, the CDN serves it from a Tokyo edge node instead. This reduces latency from 200ms to 20ms. But CDN caching only works if you configure it correctly. Poor cache key design, missing cache headers, or aggressive invalidation can make the CDN useless. The following walks through everything you need to design a CDN caching strategy that works.
How CDN Caching Works
User (Tokyo) → CDN Edge (Tokyo) → Origin (Virginia)
↓
Cache HIT → Return cached response (20ms)
Cache MISS → Fetch from origin, cache, return (200ms)
- User requests a URL (e.g.,
https://example.com/image.jpg) - CDN checks if the response is cached at the nearest edge node
- If cached (HIT): return the cached response immediately
- If not cached (MISS): fetch from origin, store at edge, return to user
- Subsequent requests for the same URL are served from cache until TTL expires
Cache Headers
CDNs use HTTP cache headers to determine what to cache and for how long.
Cache-Control
The Cache-Control header is the primary directive for CDN caching.
Cache-Control: public, max-age=3600
public: Any cache (CDN, browser) can store the responseprivate: Only the browser can store the response (not the CDN)max-age=N: Cache for N secondss-maxage=N: Cache for N seconds in shared caches (CDN) onlyno-cache: Must revalidate with origin before using cached copyno-store: Do not cache at allmust-revalidate: Do not serve stale response after expirystale-while-revalidate=N: Serve stale while revalidating in background
Setting Headers by Content Type
# nginx configuration
# Static assets: long cache, immutable
location ~* \.(css|js|png|jpg|jpeg|gif|svg|woff2?)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# HTML pages: short cache, must revalidate
location ~* \.html$ {
add_header Cache-Control "public, max-age=60, must-revalidate";
}
# API responses: no cache by default
location /api/ {
add_header Cache-Control "no-store";
}
# Public API responses: short cache
location /api/public/ {
add_header Cache-Control "public, max-age=60";
}
ETag and Last-Modified
ETag and Last-Modified enable conditional requests. When the CDN’s cached copy expires, it sends a conditional request to the origin with If-None-Match or If-Modified-Since. If the content has not changed, the origin returns 304 Not Modified (no body), and the CDN refreshes the cached copy’s TTL.
# First request
HTTP/1.1 200 OK
ETag: "abc123"
Last-Modified: Wed, 04 Jul 2026 12:00:00 GMT
Cache-Control: public, max-age=3600
Content: <html>...</html>
# Subsequent request (after TTL expires)
GET /page
If-None-Match: "abc123"
If-Modified-Since: Wed, 04 Jul 2026 12:00:00 GMT
# Origin response
HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=3600
# No body — CDN keeps cached copy
Cache Keys
The cache key determines whether two requests hit the same cached response. By default, the cache key is the full URL including query string. But you can customize it.
Default Cache Key
Cache key: https://example.com/page?utm_source=email&utm_campaign=summer
Two requests with different utm_source values produce different cache keys, even though the content is identical. This wastes cache space.
Normalizing Cache Keys
Remove tracking parameters that do not affect content:
// Cloudflare Worker: normalize cache key
addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
// Remove tracking parameters
const trackingParams = ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "fbclid", "gclid"];
for (const param of trackingParams) {
url.searchParams.delete(param);
}
// Use normalized URL as cache key
event.respondWith(fetch(url.toString()));
});
Vary Header
The Vary header tells the CDN to include additional request headers in the cache key. This is needed when the response differs based on request headers.
Vary: Accept-Encoding, Accept-Language
This creates separate cache entries for different encodings (gzip, br) and languages (en, es). Without Vary, a user requesting Spanish might get the cached English response.
Cache Key Composition
Full cache key = URL + Vary headers + Custom keys
Design your cache key to include everything that affects the response and nothing that does not.
Invalidation Strategies
TTL-Based Expiration
Set a TTL on cached content. After the TTL expires, the CDN revalidates with the origin. Simple but serves stale content for up to the TTL duration.
Cache-Control: public, max-age=300
Purge by URL
Explicitly remove a URL from the CDN cache. Use this when content changes before the TTL expires.
# Cloudflare purge by URL
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
-H "Authorization: Bearer {api_token}" \
-H "Content-Type: application/json" \
-d '{"files": ["https://example.com/page1", "https://example.com/page2"]}'
# Fastly purge by URL
curl -X POST "https://api.fastly.com/purge/{service_id}" \
-H "Fastly-Key: {api_key}" \
-d "https://example.com/page1"
Purge by Surrogate Key
Tag cached responses with surrogate keys. Purge by key to remove all tagged responses in one call. This is more efficient than purging individual URLs.
# Response headers
Surrogate-Key: product-42 products category-5
Cache-Control: public, max-age=3600
# Fastly purge by surrogate key
curl -X POST "https://api.fastly.com/purge/{service_id}" \
-H "Fastly-Key: {api_key}" \
-H "Surrogate-Key: product-42"
This purges all responses tagged with product-42 across the entire CDN.
Purge All
Purge everything from the CDN. Use sparingly: it causes a spike in origin traffic as all requests become cache misses.
# Cloudflare purge everything
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
-H "Authorization: Bearer {api_token}" \
-H "Content-Type: application/json" \
-d '{"purge_everything": true}'
Event-Driven Invalidation
Trigger CDN purge when data changes in your backend.
import requests
def update_product(product_id: int, data: dict):
product = db.products.update(product_id, data)
# Purge CDN cache for this product
requests.post(
"https://api.fastly.com/purge/{service_id}",
headers={"Fastly-Key": API_KEY},
data={"surrogate_keys": [f"product-{product_id}", "products"]}
)
return product
Stale Content Strategies
stale-while-revalidate
Serve stale content while fetching a fresh copy in the background. Users get instant responses; the cache updates asynchronously.
Cache-Control: public, max-age=300, stale-while-revalidate=60
For 300 seconds, serve from cache. From 300-360 seconds, serve stale while revalidating. After 360 seconds, fetch from origin synchronously.
stale-if-error
Serve stale content if the origin is unavailable. This provides resilience during origin outages.
Cache-Control: public, max-age=300, stale-if-error=86400
If the origin returns an error (5xx) after the cache expires, serve the stale cached content for up to 86400 seconds (1 day).
Caching Different Content Types
Static Assets (CSS, JS, Images)
Cache for a long time with content-based filenames. Use fingerprinting (hash in filename) so new versions get new URLs.
Cache-Control: public, max-age=31536000, immutable
<!-- Versioned URLs -->
<link rel="stylesheet" href="/css/main.a1b2c3d4.css">
<script src="/js/app.e5f6g7h8.js"></script>
HTML Pages
Cache for a short time with revalidation. Content changes frequently but should be fresh.
Cache-Control: public, max-age=60, must-revalidate
ETag: "abc123"
API Responses
Cache public API responses with short TTLs. Do not cache authenticated or user-specific responses.
# Public product list
Cache-Control: public, max-age=60
# User-specific data
Cache-Control: private, no-cache
# Real-time data
Cache-Control: no-store
Authentication and CDN Caching
Authenticated responses must not be cached by the CDN. Use private or no-store for any response that includes user-specific data.
# User profile (private)
Cache-Control: private, no-cache
Set-Cookie: session=abc123; HttpOnly; Secure
# Public product (cacheable)
Cache-Control: public, max-age=3600
Multi-CDN Strategy
For global applications, use multiple CDNs to optimize cost, performance, and availability.
CDN Selection by Geography
Route users to the CDN with the best performance in their region.
// DNS-based CDN routing (using a DNS provider like NS1 or Route 53)
const cdnRoutes = {
"asia": "cdn-asia.example.com", // Cloudflare (strong in Asia)
"europe": "cdn-europe.example.com", // Fastly (strong in Europe)
"default": "cdn-global.example.com", // Cloudfront (global)
};
CDN Selection by Content Type
Route static assets to one CDN and dynamic content to another.
Static assets (CSS, JS, images) → CDN A (cheaper, longer TTL)
Dynamic content (API, HTML) → CDN B (faster origin, shorter TTL)
Failover
If one CDN goes down, route traffic to the backup CDN.
# Health check script
if ! curl -s --max-time 5 https://cdn-a.example.com/health; then
# Update DNS to route to CDN B
aws route53 change-resource-record-sets \
--hosted-zone-id Z123 \
--change-batch '{"Changes":[{"Action":"UPSERT","ResourceRecordSet":{"Name":"cdn.example.com","Type":"CNAME","TTL":60,"ResourceRecords":[{"Value":"cdn-b.example.com"}]}}]}'
fi
Monitoring CDN Performance
Key Metrics
- Cache hit ratio:
hits / (hits + misses)— should be above 90% for static content - Origin shield ratio: percentage of requests served from origin shield without hitting origin
- Edge response time: p50, p95, p99 for edge-served responses
- Origin response time: p50, p95, p99 for origin fetches
- Purge latency: time from purge request to cache invalidation
- Bandwidth: CDN-served bandwidth vs origin-served bandwidth
Cache Hit Ratio Calculation
Cache Hit Ratio = Cache Hits / (Cache Hits + Cache Misses) * 100
A 95% hit ratio means 95 out of 100 requests are served from cache. The remaining 5 go to the origin. Track this per content type:
| Content Type | Target Hit Ratio | Typical TTL |
|---|---|---|
| Static assets | 99%+ | 1 year |
| HTML pages | 80-90% | 1-5 minutes |
| Public API | 60-80% | 30-60 seconds |
| Private API | 0% (no cache) | N/A |
Production Checklist
- Cache-Control headers set for all content types
- ETag or Last-Modified headers for conditional requests
- Cache keys normalized (tracking parameters removed)
- Vary header set for content negotiation (encoding, language)
- Surrogate keys for targeted purging
- Event-driven purge on data changes
- stale-while-revalidate for graceful staleness
- stale-if-error for origin outage resilience
- Static assets use fingerprinted filenames
- Authenticated responses marked private or no-store
- Cache hit ratio monitored per content type
- Purge latency monitored
- Origin shield configured to reduce origin load
- Failover plan for CDN outage
FAQ
What is the difference between browser cache and CDN cache?
Browser cache is stored on the user’s device. It reduces requests to the CDN. CDN cache is stored on edge servers. It reduces requests to the origin. Both layers work together: browser cache serves repeat visits, CDN cache serves first visits from any user in the same region.
Should I cache HTML pages on the CDN?
Yes, with a short TTL (1-5 minutes). HTML pages are expensive to generate server-side. Caching them for even 1 minute reduces origin load considerably. Use must-revalidate and ETags so the CDN revalidates efficiently.
How do I handle user-specific content with a CDN?
Do not cache user-specific content on the CDN. Mark it private or no-store. For pages with a mix of public and private content, use Edge Side Includes (ESI) or client-side rendering for the personalized parts, and cache the public parts on the CDN.
What is an origin shield?
An origin shield is a CDN cache layer between the edge nodes and your origin. All edge nodes fetch from the shield, and the shield fetches from the origin. This reduces origin load: if 100 edge nodes request the same content, only 1 request reaches the origin (from the shield).
How long should I cache static assets?
Cache static assets for 1 year (max-age=31536000). Use fingerprinted filenames (hash in filename) so new versions get new URLs. When you deploy a new version, the URL changes, and the CDN fetches the new file. Old URLs remain cached for users who have not updated.
What is the best CDN for my use case?
Depends on your priorities. Cloudflare has the widest edge network and a generous free tier. Fastly offers instant purging and surrogate keys. CloudFront integrates well with AWS. Akamai is strong for enterprise and large-scale delivery. Test multiple CDNs with real users to find the best fit.
See Also
Related Resources
Complete Guide to Redis Caching Strategies
Master Redis caching with cache-aside, read-through, write-through, write-behind, and refresh-ahead patterns. Covers eviction policies, TTL tuning, serialization, and production operations.
GuideComplete Guide to GraphQL Caching
Cache GraphQL responses at every layer: CDN, gateway, DataLoader, persisted queries, and client-side. Covers cache keys, invalidation, HTTP caching directives, and Apollo Client cache.
PatternCircuit Breaker Pattern
Prevent cascading failures by stopping requests to failing services. An architectural pattern for resilient distributed systems.
GuideComplete Guide to Cache Invalidation
Master cache invalidation strategies: TTL expiration, event-driven invalidation, versioned keys, tag-based purging, and write-through invalidation. Covers multi-tier invalidation, race conditions, and consistency patterns.
GuideComplete Guide to Application-Level Caching
Implement in-memory, distributed, and hybrid caches at the application layer. Covers LRU caches, TTL caches, multi-tier strategies, cache sizing, thread safety, and production patterns for Python, Java, and Node.js.