The Caching Imperative
Users hate slow websites. Slow = lost conversions. Every 100ms latency costs 1% of revenue (real data from Amazon, Google).
Caching is the primary lever for speed. Cache data, cache computations, cache responses. Layer caches reduce latency and load.
Cache Layers (Outside to Inside)
Layer 1: Browser cache
- User's browser caches static assets (CSS, JS, images).
- Set via Cache-Control headers (e.g., "max-age=31536000" = 1 year).
- Free, instant.
Layer 2: CDN cache
- Global network of servers. Content cached near users.
- User in Mumbai gets content from Mumbai edge server, not US origin.
- 50-100x faster than fetching from origin.
- Cost: $0.01-0.10 per GB (or fixed monthly, $20+).
Layer 3: Application cache
- Redis or Memcached. Cache expensive computations or database queries.
- Reduces database load dramatically.
- Cost: $0.10-1.00 per GB per month (depends on service).
Layer 4: Database cache
- Row cache, query result cache.
- Some databases (PostgreSQL) have built-in caching.
- Minimal additional cost.
HTTP Caching Headers
Instruct browsers and CDNs how to cache content.
Cache-Control: public, max-age=3600
^ Cache-Control header
^ public = CDNs can cache
^ max-age=3600 = cache for 3600 seconds (1 hour)
Strategies:
-
Static assets (CSS, JS):
Cache-Control: public, max-age=31536000(1 year)- Add hash to filename: app.a1b2c3.js
- When code changes, filename changes, browser fetches new version
- Old version stays in cache forever (doesn't waste space)
-
HTML pages:
Cache-Control: public, max-age=300(5 minutes)- Short cache so updates are visible quickly
- Or use:
Cache-Control: no-cache+ ETags for conditional requests
-
API responses:
Cache-Control: private, max-age=60(1 minute)- private = don't cache at CDN, only browser
- Or use Redis for server-side caching
Redis for Application Caching
Redis is a fast in-memory database. Cache expensive queries and computations.
import redis
from functools import wraps
cache = redis.Redis(host='localhost', port=6379, db=0)
def get_user_profile(user_id):
# Try cache first
cached = cache.get(f'user:{user_id}')
if cached:
return json.loads(cached)
# Cache miss, query database
user = database.query(f'SELECT * FROM users WHERE id = {user_id}')
# Save to cache for 1 hour
cache.setex(f'user:{user_id}', 3600, json.dumps(user))
return user
Cache invalidation: When data changes, invalidate cache.
def update_user_profile(user_id, data):
database.update(f'UPDATE users SET ... WHERE id = {user_id}')
cache.delete(f'user:{user_id}') # Invalidate cache
Cache stampede: If cache expires and 1000 requests hit simultaneously, they all query the database.
Prevent with:
- Longer cache TTL (time-to-live)
- Probabilistic early expiration (recompute before expiration)
- Mutual exclusion (only one thread recomputes, others wait)
CDN for Global Performance
CDNs cache content near users. Cloudflare, Fastly, AWS CloudFront, etc.
How it works:
- User in India requests myapp.com/logo.png
- CDN edge server in India checks cache
- Cache hit: serve instantly from India (2ms latency)
- Cache miss: fetch from origin (US), serve to user, cache for future
Cost: $0.01-0.10 per GB (outbound data). For 1TB/month, $10-100. Expensive at scale, but ROI is high (performance = conversions).
Cache control at CDN:
Cache-Control: public, max-age=3600, s-maxage=86400
^ s-maxage = CDN cache TTL (1 day)
^ max-age = browser cache TTL (1 hour)
Tell CDN to cache for 1 day, browser for 1 hour.
Edge Computing: Compute at the Edge
Cloudflare Workers, AWS Lambda@Edge: run code at edge servers.
Use for:
- Rewrite URLs based on user location
- A/B testing (serve variant based on cookie)
- Authentication (check JWT before routing)
- Rate limiting
// Cloudflare Worker
export default {
fetch: (request) => {
if (request.headers.get('X-Test-User')) {
return fetch('https://variant-b.myapp.com' + request.url.pathname);
}
return fetch('https://variant-a.myapp.com' + request.url.pathname);
}
}
Reduces latency by computing near users instead of centralizing.
Cache Invalidation Strategies
"There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton
Time-based (TTL):
- Set expiration: cache expires after N seconds
- Simple, but stale data risk
Event-based (active invalidation):
- When data changes, explicitly invalidate cache
- Fast, but requires coordination
Hybrid:
- Set TTL to 1 hour
- Invalidate immediately when data changes
- If invalidation fails, TTL is fallback
Example:
def publish_blog_post(post_id):
database.insert(post)
# Invalidate cache
try:
cache.delete(f'post:{post_id}')
except:
pass # If cache is down, TTL will clean up
Caching Checklist
- Set HTTP cache headers (Cache-Control, ETag)
- Use hashed filenames for static assets
- Deploy CDN for static content
- Implement Redis for application caching
- Cache frequently accessed queries
- Set reasonable TTLs (1 minute to 1 hour for most data)
- Implement active cache invalidation
- Monitor cache hit rate (target: >80%)
- Monitor CDN performance (latency, hit rate)
- Test cache behavior (verify correctness)
Frequently asked questions
What cache hit rate should we target?
80%+ for most applications. If hit rate is <50%, your cache is ineffective. Adjust TTLs or cache different data. Monitor with metrics: (cache_hits) / (cache_hits + cache_misses).
Should we cache database connections or queries?
Both. Connection pooling caches connections (reduces overhead of creating new connections). Query caching (Redis) caches results. Database-level caching (PostgreSQL buffer pool) is built-in. Use all three layers.
What happens if cached data is wrong?
Serve stale data until cache expires (or is invalidated). Users might see outdated information for a few seconds/minutes. This is acceptable for most data. For critical data (account balance, permission), use shorter TTLs or invalidate immediately.