CDN Caching: Global Content Distribution at Scale
Cache Keys and TTL: Controlling What Gets Cached and For How Long
Read aloud is unavailable in this browser.
The Cache Identity Problem
Imagine two users requesting what appears to be the same URL:
https://shop.example.com/products/shoes— User in US, English localehttps://shop.example.com/products/shoes— User in France, French locale
Should the CDN serve a cached response from the first request to the second user? Clearly not—they expect different content. But how does the CDN know these are different resources?
This is the cache key problem: determining the unique identity of cacheable content. Closely related is the TTL problem: how long should cached content be considered valid before requiring revalidation?
These two mechanisms—cache keys and TTL—are the primary controls architects use to configure CDN behavior. Understanding them deeply is essential for achieving high cache hit rates while avoiding stale or incorrect content delivery.
By the end of this page, you will understand how CDNs construct cache keys from request components, how to configure cache keys for complex content personalization scenarios, the complete hierarchy of TTL configuration sources, and best practices for balancing cache efficiency with content freshness. You'll be equipped to optimize CDN configurations for maximum performance.
Understanding Cache Keys
A cache key is a unique identifier that the CDN uses to store and retrieve cached content. When a request arrives at an edge server, the CDN computes the cache key for that request and checks if a cached response exists with that key. If found (cache hit), the cached content is returned; if not (cache miss), the request is forwarded to the origin.
Default Cache Key Components:
By default, most CDNs construct cache keys from:
- Host Header: The domain name in the request (
cdn.example.com) - Request Path: The URL path (
/products/shoes) - Query String: URL parameters (
?color=red&size=10)
This default produces cache keys like: cdn.example.com|/products/shoes|?color=red&size=10
Why Cache Keys Matter:
Under-keying (cache key too simple):
- Different content gets the same cache key
- Users receive incorrect content (e.g., wrong locale, wrong user data)
- Security issues: one user seeing another's personalized content
Over-keying (cache key too complex):
- Same content gets different cache keys
- Lower cache hit rates; more origin load
- Wasted edge storage on duplicate content
The art of cache key configuration is finding the minimal key that correctly distinguishes between distinct content.
| Scenario | Cache Key Issue | Consequence |
|---|---|---|
| Same page, different locales | Under-keying (no locale in key) | French user sees English content |
| Same image with tracking parameters | Over-keying (full query string) | Same image cached 1000x with different keys |
| A/B test variants | Under-keying (no variant ID) | Users see wrong experiment variant |
| Unnecessary headers in key | Over-keying (User-Agent included) | Same content cached per browser version |
Cache key configuration errors often don't produce obvious failures. Under-keyed content appears to work—until a user reports seeing wrong content. Over-keyed content also works—but with degraded cache hit rates that may not be immediately noticed. Invest time in correct cache key design upfront.
Cache Key Components: A Deep Dive
Let's examine each potential cache key component and understand when to include or exclude it.
Host / Domain:
- •Include (default): Multiple domains on same CDN serve different content
- •Normalize:
www.example.comandexample.comshould often share cache keys - •Exclude: Single-site CDN configurations where host is redundant
- •Always Include: Path identifies the resource; essential for cache key
- •Normalize Case:
/Products/Shoesvs/products/shoes—many CDNs match insensitively - •Trailing Slash Normalization:
/products/vs/products—should these be same key?
Query String Handling:
Query strings are where cache key configuration gets complex. Consider these URLs:
/products?category=shoes&sort=price&utm_source=google
/products?sort=price&category=shoes&fbclid=abc123
/products?category=shoes
Should these share a cache key?
categoryandsortaffect contentutm_sourceandfbclidare tracking parameters—they don't change content- Parameter order technically makes URLs different, but content is the same
Query String Strategies:
| Strategy | Description | Use Case |
|---|---|---|
| Include All | Full query string in cache key | Every parameter affects content (rare) |
| Exclude All | Ignore query string entirely | Static assets where params are irrelevant |
| Include Specific | Whitelist: only specified params in key | Known content-affecting params (category, locale) |
| Exclude Specific | Blacklist: remove specified params | Known tracking params (utm_*, fbclid) |
| Sort Parameters | Normalize param order before keying | Prevent order-based cache fragmentation |
Headers in Cache Keys:
SOme content varies based on HTTP headers:
| Header | When to Include in Key |
|---|---|
Accept-Language | Multi-language sites serving different content per locale |
Accept-Encoding | Usually handled separately (content-encoding negotiation) |
Cookie | Personalized/authenticated content (use carefully!) |
Accept | Content negotiation (JSON vs HTML for same URL) |
User-Agent | Rarely—mobile vs desktop sites if using same URL |
X-Forwarded-Proto | HTTP vs HTTPS responses differ (usually normalized) |
Cookie-Based Cache Keys:
Including cookies in cache keys enables serving personalized content from cache:
Cache-Key: host + path + cookie:locale + cookie:ab_variant
But this creates per-user cache fragmentation. Best practice:
- Cache anonymous content aggressively
- Use edge compute for personalization (fetch from cache, transform at edge)
- Include only specific, structured cookies (locale, variant ID) not full cookie header
Begin with the simplest cache key that produces correct behavior (host + path). Add query params only if needed for content differentiation. Add headers only with clear justification. Every addition to the cache key reduces cache efficiency—make each component earn its place.
Cache Key Configuration Patterns
Real-world applications require sophisticated cache key strategies. Here are common patterns and their implementations.
Pattern 1: Static Asset Optimization
For images, CSS, JS files:
Path: /static/*
Cache Key: Host + Path only
Query String: Ignored entirely
Headers: None
Rationale: Static assets are immutable at a given path. Query strings are typically cache-busting versions or tracking parameters that shouldn't fragment the cache.
Pattern 2: API Response Caching
For cacheable API endpoints:
Path: /api/products/*
Cache Key: Host + Path + Selected Query Params (category, page, limit)
Excluded Params: timestamp, requestId, tracking params
Headers: Accept (if content negotiation used)
Rationale: API responses vary by business-logic parameters but not by housekeeping parameters.
Pattern 3: Localized Content
For multi-language websites:
Path: /*
Cache Key: Host + Path + Query String + Accept-Language (normalized)
Alternative: Host + Path + Query String + locale cookie
Rationale: Same URL serves different content per locale. Normalization (en-US → en) prevents over-fragmentation.
Pattern 4: A/B Testing Variants
For experiment-aware caching:
Path: /*
Cache Key: Host + Path + Query String + experiment_variant (from cookie or header)
Rationale: Different experiment variants must be cached separately. Using a dedicated experiment identifier (not user ID) keeps fragmentation bounded.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
// Cloudflare Worker - Custom Cache Keyasync function handleRequest(request) { const url = new URL(request.url); // Build custom cache key const cacheKeyParts = [ url.hostname, url.pathname, ]; // Include only content-affecting query params const relevantParams = ['category', 'sort', 'page', 'locale']; const searchParams = new URLSearchParams(); relevantParams.forEach(param => { if (url.searchParams.has(param)) { searchParams.set(param, url.searchParams.get(param)); } }); // Sort params for consistent key generation searchParams.sort(); if (searchParams.toString()) { cacheKeyParts.push(searchParams.toString()); } // Include locale from Accept-Language if present const acceptLang = request.headers.get('Accept-Language'); if (acceptLang) { const primaryLang = acceptLang.split(',')[0].split('-')[0]; // Normalize cacheKeyParts.push(`lang:${primaryLang}`); } const cacheKey = cacheKeyParts.join('|'); // Use custom cache key for fetching const cache = caches.default; const cacheKeyRequest = new Request( `https://cache-key/${encodeURIComponent(cacheKey)}`, request ); let response = await cache.match(cacheKeyRequest); if (response) { return response; } // Cache miss - fetch from origin response = await fetch(request); // Store with custom cache key const responseToCache = response.clone(); event.waitUntil(cache.put(cacheKeyRequest, responseToCache)); return response;}Cache key configuration syntax varies by CDN provider. Cloudflare uses Page Rules, Cache Rules, and Workers. Fastly uses VCL. Akamai uses Property Manager behaviors. AWS CloudFront uses Cache Policies. The concepts are universal; the implementation details differ.
Understanding Time-To-Live (TTL)
Time-To-Live (TTL) defines how long cached content remains valid before the CDN must revalidate or re-fetch it from the origin. TTL is the primary mechanism for balancing cache efficiency (longer TTL = more hits) with content freshness (shorter TTL = more timely updates).
TTL Sources (Priority Order):
CDNs determine TTL from multiple sources, typically evaluated in this order:
- CDN Override Rules: CDN configuration that overrides origin headers (highest priority)
- Cache-Control: s-maxage: Shared cache directive from origin response
- Cache-Control: max-age: Cache directive from origin response
- Expires Header: Specific expiration timestamp (legacy)
- CDN Default TTL: Fallback when origin provides no caching directives
| Directive | Purpose | CDN Behavior |
|---|---|---|
| max-age=N | Content valid for N seconds | Cache for N seconds (unless s-maxage present) |
| s-maxage=N | Shared cache validity (N seconds) | Cache for N seconds (overrides max-age for CDN) |
| public | Response can be cached by shared caches | Explicitly cacheable |
| private | Response is user-specific | Do NOT cache at CDN |
| no-cache | Must revalidate before use | Cache but always revalidate |
| no-store | Never cache | Must not cache (bypass CDN) |
| stale-while-revalidate=N | Serve stale for N seconds while revalidating | Serve stale, async revalidate in background |
| stale-if-error=N | Serve stale if origin errors | Fallback to stale if origin returns 5xx |
The TTL Trade-off:
| Concern | Short TTL (e.g., 60s) | Long TTL (e.g., 1 day) |
|---|---|---|
| Cache Hit Rate | Lower (more revalidation) | Higher (more hits) |
| Origin Load | Higher (more requests) | Lower (fewer requests) |
| Content Freshness | More current | More stale |
| Update Propagation | ~60 seconds | ~24 hours (without purge) |
| Bandwidth Costs | Higher (duplicate transfers) | Lower |
Understanding Stale Content:
When TTL expires, content becomes stale. The CDN's behavior with stale content depends on configuration:
- Strict Freshness: Stale content is never served; CDN blocks until revalidation completes
- Stale-While-Revalidate: Stale content served immediately; revalidation happens async
- Stale-If-Error: Stale content served if origin returns error (failover)
Modern CDNs strongly favor stale-while-revalidate for performance—serving slightly stale content is almost always preferable to adding latency.
Use s-maxage to set CDN cache duration independent of browser cache duration. Example: Cache-Control: max-age=60, s-maxage=3600 tells browsers to cache for 1 minute but CDN can cache for 1 hour. This allows long CDN caching with short browser caching for faster updates (browser fetches from CDN after 60s, CDN still has content).
TTL Strategy Patterns
Different content types warrant different TTL strategies. Here's a comprehensive framework for TTL configuration.
Immutable Content Strategy:
For versioned/fingerprinted assets (content that never changes at a given URL):
URL Pattern: /static/app.a1b2c3d4.js, /images/product-v2.png
TTL: 1 year (31536000 seconds)
Headers: Cache-Control: public, max-age=31536000, immutable
Rationale: Content is immutable—the filename changes when content changes. Maximum caching is safe and optimal.
Frequently Updated Content Strategy:
For content that updates regularly (news, stock prices, dashboards):
URL Pattern: /api/prices, /news/latest
TTL: 60-300 seconds
Headers: Cache-Control: public, s-maxage=60, stale-while-revalidate=300
Rationale: Balance freshness with caching. stale-while-revalidate ensures fast responses even during revalidation.
Semi-Static Content Strategy:
For content that changes occasionally (product pages, articles):
URL Pattern: /products/*, /articles/*
TTL: 3600-86400 seconds (1 hour to 1 day)
Headers: Cache-Control: public, s-maxage=3600, stale-while-revalidate=86400
Invalidation: Event-driven purge when content updates
Rationale: Long cache duration with on-demand invalidation for updates. stale-while-revalidate provides buffer for invalidation propagation.
Never Cache Strategy:
For personalized or sensitive content:
URL Pattern: /api/me, /dashboard, /checkout
TTL: 0 (no caching)
Headers: Cache-Control: private, no-store
Rationale: User-specific content must never be cached at CDN layer.
- •Use Immutable for Versioned Assets — If filenames include hashes, set max-age to 1 year with immutable directive. This is the most efficient caching pattern.
- •Leverage stale-while-revalidate — Almost always preferable to blocking on revalidation. Users get fast responses; freshness is maintained async.
- •Separate CDN and Browser TTL — Use s-maxage for CDN and max-age for browsers. Long CDN TTL + short browser TTL = fast CDN updates with revalidation.
- •Default to Shorter TTL, Extend with Monitoring — Start conservative, increase TTL as you gain confidence in invalidation and monitor cache hit rates.
- •Plan for Invalidation — Long TTL only works if you have reliable invalidation for updates. Without invalidation, TTL is your only freshness mechanism.
A common mistake is making content uncacheable when it should be cached with short TTL. Even TTL=10 seconds provides 90%+ cache hit rate under sustained traffic. Don't default to no-cache when s-maxage=60 with stale-while-revalidate would provide good freshness and dramatically better performance.
TTL Configuration Examples
Here are concrete examples of TTL configuration for common CDN platforms.
Origin Server Headers (Node.js/Express):
12345678910111213141516171819202122232425262728293031323334353637383940414243
const express = require('express');const app = express(); // Middleware to set cache control headers based on pathfunction cacheControl(req, res, next) { const path = req.path; // Immutable static assets (fingerprinted filenames) if (path.match(/\.(js|css)$/) && path.includes('.')) { res.set('Cache-Control', 'public, max-age=31536000, immutable'); return next(); } // Images and fonts - long cache with revalidation if (path.match(/\.(png|jpg|jpeg|gif|svg|woff2|woff)$/)) { res.set('Cache-Control', 'public, max-age=86400, s-maxage=604800, stale-while-revalidate=86400'); return next(); } // API responses - short cache, async revalidation if (path.startsWith('/api/')) { res.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300, stale-if-error=86400'); return next(); } // HTML pages - moderate cache if (path.match(/\.html$/) || path === '/') { res.set('Cache-Control', 'public, s-maxage=300, stale-while-revalidate=3600'); return next(); } // User-specific pages - no caching if (path.startsWith('/dashboard') || path.startsWith('/account')) { res.set('Cache-Control', 'private, no-store'); return next(); } // Default res.set('Cache-Control', 'public, s-maxage=60'); next();} app.use(cacheControl);CDN Configuration Examples:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
# Cloudflare Ruleset for Cache Rulesresource "cloudflare_ruleset" "cache_rules" { zone_id = var.zone_id name = "Cache Rules" description = "Caching configuration" kind = "zone" phase = "http_request_cache_settings" # Immutable static assets rules { action = "set_cache_settings" action_parameters { edge_ttl { mode = "override_origin" default = 31536000 # 1 year } browser_ttl { mode = "override_origin" default = 31536000 } } expression = "(http.request.uri.path.extension in {\"js\" \"css\"} and http.request.uri.path contains \"-\")" description = "Fingerprinted assets - immutable caching" enabled = true } # API responses rules { action = "set_cache_settings" action_parameters { edge_ttl { mode = "override_origin" default = 60 } serve_stale { disable_stale_while_updating = false } } expression = "starts_with(http.request.uri.path, \"/api/\")" description = "API responses - short TTL with stale-while-revalidate" enabled = true } # Bypass cache for authenticated routes rules { action = "set_cache_settings" action_parameters { cache = false } expression = "starts_with(http.request.uri.path, \"/dashboard\") or starts_with(http.request.uri.path, \"/account\")" description = "User-specific pages - bypass cache" enabled = true }}Best practice: Set correct Cache-Control headers at the origin and let the CDN respect them. Use CDN overrides only when you can't modify origin behavior or need CDN-specific caching policies. Origin-controlled caching is more maintainable and works consistently across CDN providers.
Cache Variance and the Vary Header
The Vary header is a critical mechanism for indicating that responses differ based on request headers. It directly impacts how CDNs construct cache keys.
How Vary Works:
When an origin responds with Vary: Accept-Language, it's telling caches: "Responses for this URL differ based on the Accept-Language header. Include Accept-Language in your cache key."
Common Vary Header Values:
| Vary Value | Use Case | CDN Impact |
|---|---|---|
| Accept-Encoding | Compressed vs uncompressed responses | CDN maintains gzip, br, identity variants |
| Accept-Language | Localized content | Cache per language preference |
| Accept | Content negotiation (HTML vs JSON) | Cache per content type |
| Cookie | Personalized content | DANGER: Per-user cache fragmentation |
| User-Agent | Device-specific responses | DANGER: Massive cache fragmentation |
| Origin | CORS preflight caching | Cache per requesting origin |
The Vary Pitfalls:
Vary: Cookie — Technically correct for personalized content, but creates per-user cache entries. Every unique cookie value creates a separate cache entry. For anonymous users with session cookies, this effectively disables caching.
Vary: User-Agent — User-Agent strings are nearly unique per browser version. Vary: User-Agent creates separate cache entries for Firefox 98.0, Firefox 98.0.1, Chrome 99, Chrome 100, etc. This fragments the cache into unusability.
The Solution: Explicit Header Normalization
Instead of varying on high-cardinality headers, normalize to low-cardinality values:
# Instead of: Vary: Cookie
# Normalize cookie to specific values:
X-Cache-Vary: locale=en|ab-variant=control
# Instead of: Vary: User-Agent
# Normalize to device class:
X-Device-Type: mobile | tablet | desktop
Vary: X-Device-Type
Many CDNs can perform this normalization at the edge or through configuration.
123456789101112131415161718192021222324252627282930313233
// Normalize high-cardinality headers for cachingasync function handleRequest(request) { const modifiedHeaders = new Headers(request.headers); // Normalize Accept-Language to primary language only const acceptLang = request.headers.get('Accept-Language') || 'en'; const primaryLang = acceptLang.split(',')[0].split(';')[0].split('-')[0].toLowerCase(); modifiedHeaders.set('X-Normalized-Lang', primaryLang); // Normalize User-Agent to device class const userAgent = request.headers.get('User-Agent') || ''; let deviceType = 'desktop'; if (/Mobile|Android|iPhone/i.test(userAgent)) { deviceType = 'mobile'; } else if (/iPad|Tablet/i.test(userAgent)) { deviceType = 'tablet'; } modifiedHeaders.set('X-Device-Type', deviceType); // Normalize cookie to cache-relevant values only const cookies = request.headers.get('Cookie') || ''; const locale = cookies.match(/locale=([^;]+)/)?.[1] || 'en'; const abVariant = cookies.match(/ab_variant=([^;]+)/)?.[1] || 'control'; modifiedHeaders.set('X-Cache-Key-Cookie', `locale=${locale}|variant=${abVariant}`); const modifiedRequest = new Request(request.url, { method: request.method, headers: modifiedHeaders, body: request.body, }); return fetch(modifiedRequest);}Audit your origin responses for Vary headers. A single Vary: Cookie response on your homepage can destroy your cache hit rate. Use CDN analytics to identify responses with problematic Vary headers and fix them at the origin or normalize at the edge.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.