Loading content...
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 localeShould 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.
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:
cdn.example.com)/products/shoes)?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):
Over-keying (cache key too complex):
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.
Let's examine each potential cache key component and understand when to include or exclude it.
Host / Domain:
www.example.com and example.com should often share cache keys/Products/Shoes vs /products/shoes—many CDNs match insensitively/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?
category and sort affect contentutm_source and fbclid are tracking parameters—they don't change contentQuery 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:
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.
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.
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:
| 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:
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).
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.
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.
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.
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.
We've completed an exhaustive exploration of cache keys and TTL—the fundamental mechanisms that govern CDN caching behavior and determine cache efficiency.
Next Steps:
With cache keys and TTL mastered, we'll next explore Cache Invalidation at CDN—the mechanisms for purging stale content when updates occur, including instant purge, surrogate keys, and soft purge strategies.
You now possess comprehensive knowledge of cache key construction and TTL configuration—the primary controls for CDN caching behavior. This understanding enables you to optimize cache hit rates, ensure content correctness, and balance freshness with performance for globally distributed applications.