Cache Content Negotiation: Caching Based on Request Headers and Variants
In this tutorial, you will learn about Cache Content Negotiation: Caching Based on Request Headers and Variants. We cover key concepts, practical examples, and best practices to help you master this topic.
Cache content negotiation stores and serves different cached variants of the same resource based on request headers like language, encoding, and device type, maximizing cache hit rates while delivering personalized content to each user segment.
flowchart LR
Request[HTTP Request] --> Negotiate[Content Negotiation]
Negotiate --> Language[Accept-Language: en, fr, es]
Negotiate --> Encoding[Accept-Encoding: gzip, br]
Negotiate --> Device[User-Agent: mobile, desktop]
Language --> VariantKey[Cache Key: /page | en | gzip | mobile]
Encoding --> VariantKey
Device --> VariantKey
VariantKey --> Cache{Cached Variant?}
Cache -->|Hit| Serve[Serve Variant]
Cache -->|Miss| Generate[Generate & Cache Variant]
What You'll Learn
- Vary header usage for cache variant negotiation
- Cache key construction with request header dimensions
- Language, encoding, and device-aware caching
- Balancing personalization with cache efficiency
Why It Matters
Without content negotiation, you either serve the same cached response to everyone (losing language/device optimization) or bypass the cache entirely for personalized content (increasing origin load). Content negotiation caching achieves both goals simultaneously.
Real-World Use
DodaTech's documentation site serves cached content in 8 languages across desktop and mobile variants. By including Accept-Language and User-Agent in the cache key, the CDN achieves a 92% cache hit rate while serving language-appropriate, device-optimized HTML. Without negotiation, they would either serve English-only pages or bypass the CDN entirely.
Language-Aware Cache Key
Build cache keys from request headers:
import redis
import json
import hashlib
r = redis.Redis(decode_responses=True)
class LanguageAwareCache:
def __init__(self, redis_client):
self.r = redis_client
self.supported_languages = ["en", "fr", "es", "de", "ja", "zh"]
self.default_language = "en"
def parse_accept_language(self, header):
"""Parse Accept-Language header and select best match."""
if not header:
return self.default_language
languages = []
for part in header.split(","):
parts = part.strip().split(";")
lang = parts[0].split("-")[0]
q = 1.0
if len(parts) > 1 and parts[1].startswith("q="):
q = float(parts[1][2:])
languages.append((lang, q))
languages.sort(key=lambda x: -x[1])
for lang, _ in languages:
if lang in self.supported_languages or lang[:2] in self.supported_languages:
return lang[:2] if lang[:2] in self.supported_languages else self.default_language
return self.default_language
def cache_key(self, base_key, language):
"""Generate a language-specific cache key."""
return f"{base_key}:lang:{language}"
def get(self, base_key, accept_language_header, fetch_fn, ttl=3600):
"""Get language-specific cached content."""
language = self.parse_accept_language(accept_language_header)
key = self.cache_key(base_key, language)
cached = self.r.get(key)
if cached:
return {"value": json.loads(cached), "language": language, "source": "cache"}
value = fetch_fn(base_key, language)
if value:
self.r.setex(key, ttl, json.dumps(value))
return {"value": value, "language": language, "source": "generated"}
def warm_languages(self, base_key, fetch_fn, ttl=3600):
"""Pre-generate and cache content for all supported languages."""
results = {}
for lang in self.supported_languages:
key = self.cache_key(base_key, lang)
if not self.r.exists(key):
value = fetch_fn(base_key, lang)
if value:
self.r.setex(key, ttl, json.dumps(value))
results[lang] = "warmed"
else:
results[lang] = "failed"
else:
results[lang] = "already_cached"
return results
cache = LanguageAwareCache(r)
def generate_content(base_key, language):
content_map = {
"en": "Hello World",
"fr": "Bonjour le Monde",
"es": "Hola Mundo",
"de": "Hallo Welt",
}
return {"title": content_map.get(language, "Hello"), "lang": language}
headers = [
"en-US,en;q=0.9,fr;q=0.8",
"fr-FR,fr;q=0.9,en;q=0.5",
"es-MX,es;q=0.9,en;q=0.4",
None,
]
for header in headers:
result = cache.get("page:welcome", header, generate_content)
print(f"Accept: {str(header):30s} -> {result['language']:4s} ({result['source']})")
result = cache.warm_languages("page:about", generate_content)
print(f"\nLanguage warming: {result}")
Expected output:
Accept: en-US,en;q=0.9,fr;q=0.8 -> en (generated)
Accept: fr-FR,fr;q=0.9,en;q=0.5 -> fr (generated)
Accept: es-MX,es;q=0.9,en;q=0.4 -> es (generated)
Accept: None -> en (generated)
Language warming: {'en': 'warmed', 'fr': 'warmed', ...}
Device-Aware Cache Variants
Cache different layouts for desktop and mobile:
import redis
import json
import re
r = redis.Redis(decode_responses=True)
class DeviceAwareCache:
def __init__(self, redis_client):
self.r = redis_client
def detect_device(self, user_agent):
"""Detect device type from User-Agent string."""
if not user_agent:
return "desktop"
mobile_patterns = [
"mobile", "android", "iphone", "ipad", "ipod",
"blackberry", "opera mini", "iemobile", "wpdesktop"
]
ua_lower = user_agent.lower()
for pattern in mobile_patterns:
if pattern in ua_lower:
return "mobile"
return "desktop"
def cache_key(self, base_key, device):
"""Generate a device-specific cache key."""
return f"{base_key}:dev:{device}"
def get(self, base_key, user_agent, fetch_fn, ttl=3600):
"""Get device-specific cached content."""
device = self.detect_device(user_agent)
key = self.cache_key(base_key, device)
cached = self.r.get(key)
if cached:
return {
"value": json.loads(cached),
"device": device,
"source": "cache",
"key": key,
}
value = fetch_fn(base_key, device)
self.r.setex(key, ttl, json.dumps(value))
return {"value": value, "device": device, "source": "generated", "key": key}
def warm_devices(self, base_key, fetch_fn, ttl=3600):
"""Pre-cache content for both desktop and mobile."""
results = {}
for device in ["desktop", "mobile"]:
key = self.cache_key(base_key, device)
value = fetch_fn(base_key, device)
self.r.setex(key, ttl, json.dumps(value))
results[device] = True
return results
device_cache = DeviceAwareCache(r)
def render_page(base_key, device):
if device == "mobile":
return {"layout": "mobile", "content": f"{base_key} mobile view"}
return {"layout": "desktop", "content": f"{base_key} desktop view"}
requests = [
("page:dashboard", "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0)"),
("page:dashboard", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"),
("page:dashboard", "Mozilla/5.0 (Linux; Android 11)"),
]
for key, ua in requests:
result = device_cache.get(key, ua, render_page)
print(f"Device: {ua[:30]:30s} -> {result['device']:8s} ({result['source']})")
device_cache.warm_devices("page:settings", render_page)
print("\nWarmed both variants:")
for device in ["desktop", "mobile"]:
key = device_cache.cache_key("page:settings", device)
print(f" {key}: cached={r.exists(key)}")
Expected output:
Device: Mozilla/5.0 (iPhone; CPU iPhone -> mobile (generated)
Device: Mozilla/5.0 (Windows NT 10.0; Wi -> desktop (generated)
Device: Mozilla/5.0 (Linux; Android 11) -> mobile (generated)
Warmed both variants:
page:settings:dev:desktop: cached=True
page:settings:dev:mobile: cached=True
Multi-Dimension Cache Key
Combine multiple header dimensions into a single key:
import redis
import json
import hashlib
r = redis.Redis(decode_responses=True)
class MultiDimCache:
def __init__(self, redis_client):
self.r = redis_client
def build_key(self, base_key, dimensions):
"""Build a cache key from multiple dimensions."""
dim_parts = []
for dim_name, dim_value in sorted(dimensions.items()):
dim_parts.append(f"{dim_name}={dim_value}")
dim_string = "&".join(dim_parts)
key = f"multi:{base_key}:{hashlib.md5(dim_string.encode()).hexdigest()[:8]}"
return key, dim_string
def get(self, base_key, dimensions, fetch_fn, ttl=3600):
"""Get content with multi-dimensional cache key."""
cache_key, dim_string = self.build_key(base_key, dimensions)
cached = self.r.get(cache_key)
if cached:
return {"value": json.loads(cached), "source": "cache", "key": cache_key}
value = fetch_fn(base_key, dimensions)
self.r.setex(cache_key, ttl, json.dumps(value))
return {"value": value, "source": "generated", "key": cache_key}
def get_variant_count(self, base_key):
"""Count cached variants for a resource."""
pattern = f"multi:{base_key}:*"
keys = self.r.keys(pattern)
return len(keys) if keys else 0
multi_cache = MultiDimCache(r)
def generate_content(base_key, dims):
return {
"message": f"Content for {dims['lang']} on {dims['device']}",
"dimensions": dims,
}
variants = [
{"lang": "en", "device": "desktop", "currency": "USD"},
{"lang": "en", "device": "mobile", "currency": "USD"},
{"lang": "fr", "device": "desktop", "currency": "EUR"},
{"lang": "fr", "device": "mobile", "currency": "EUR"},
{"lang": "ja", "device": "mobile", "currency": "JPY"},
]
for dims in variants:
result = multi_cache.get("page:pricing", dims, generate_content)
print(f"Variant {str(dims):50s} -> {result['source']}")
print(f"\nTotal cached variants: {multi_cache.get_variant_count('page:pricing')}")
Expected output:
Variant {'lang': 'en', 'device': 'desktop', 'currency': 'USD'} -> generated
Variant {'lang': 'en', 'device': 'mobile', 'currency': 'USD'} -> generated
Variant {'lang': 'fr', 'device': 'desktop', 'currency': 'EUR'} -> generated
Variant {'lang': 'fr', 'device': 'mobile', 'currency': 'EUR'} -> generated
Variant {'lang': 'ja', 'device': 'mobile', 'currency': 'JPY'} -> generated
Total cached variants: 5
Common Mistakes
- Creating too many cache variants — dimensions multiply. 2 devices x 8 languages x 3 currencies = 48 variants per resource. Each variant fragments the cache. Include only dimensions that genuinely change the response.
- Not using a Vary header with CDN caches — CDNs need the Vary response header to know which request headers affect the response. Without Vary, CDNs cache only one version.
- Including dimensions that change per-user — user_id in the cache key creates N variants per user, making caching useless. Use dimensions with small cardinalities (2-10 values).
- Forgetting to purge all variants on update — when underlying content changes, invalidate all variants of the resource, not just the one for the current request.
- Using headers with high cardinality — Accept-Encoding has 3 common values (identity, gzip, br). Accept-Language has hundreds. Normalize Accept-Language to a supported language code before including it in the cache key.
Practice Questions
- What is the Vary HTTP header and why is it important for content negotiation caching?
- How do normalized cache keys improve cache hit rates?
- What dimensions should NOT be included in a cache key?
- How do you invalidate all variants of a cache resource?
- What is the trade-off between personalization and cache efficiency?
Challenge
Design a content negotiation cache for a global e-commerce site with: 10 supported languages, 3 device types (desktop/tablet/mobile), and 5 currencies. Calculate the number of cache variants per product page. Propose a Strategy to reduce variants by using edge-side includes (ESI) or client-side rendering for user-specific elements while caching the shared product content.
FAQ
Mini Project
Build a content negotiation cache analyzer that: (1) parses request logs to extract Accept-Language, User-Agent, and Accept-Encoding headers, (2) calculates the unique cache variants that would be created with different dimension combinations, (3) estimates the cache hit rate for each strategy, (4) recommends an optimal dimension set that balances personalization with cache efficiency, and (5) simulates the hit rate improvement from header normalization.
What's Next
Continue with Cache-Friendly API Design to learn how to design REST APIs optimized for caching. Then explore Cache Security for securing your cache layer against common attacks.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro