Performance Tips · 2 of 2
Master multi-level caching, cache warming, invalidation patterns, and monitoring cache health.
Caching is one of the most effective ways to reduce latency and database load. However, cache invalidation is notoriously difficult ("There are only two hard things in Computer Science: cache invalidation and naming things").
Data stored in application memory (e.g., hashmap).
Pros:
Cons:
Use Case: Frequently accessed, rarely-changing data (config, feature flags).
Data stored in a central in-memory datastore accessible by all app instances.
Pros:
Cons:
Use Case: Session data, computed results, rate limit counters.
Edge servers near users cache responses.
Pros:
Cons:
Use Case: Public assets (images, CSS, API responses for read-heavy endpoints).
Cache expires after a set duration.
cache.set("user:123", user_data, ttl=300) # 5 minutes
Pros:
Cons:
When to Use: Loosely consistent data (recommendations, rankings).
Explicitly invalidate cache when data changes.
# When user updates profile
cache.delete("user:123")
# or
cache.delete("user:*") # Invalidate all user keys (expensive)
Pros:
Cons:
When to Use: Critical data (account balance, auth tokens).
Group related cache entries with tags.
cache.set("user:123", user_data, tags=["user:123", "users"])
cache.set("user:123:posts", posts, tags=["user:123", "posts"])
# Invalidate all related entries
cache.invalidate_by_tag("user:123") # Invalidates both
Include a version in the cache key; change version to invalidate.
# Version 1
cache.set("user:123:v1", user_data)
# Data changed, increment version
cache.set("user:123:v2", new_user_data)
# Old version automatically orphaned
Problem: When a cached item expires, many concurrent requests miss the cache and all hit the database simultaneously.
Cache expires for "popular-item"
↓
100 concurrent requests → all miss cache
↓
All 100 hit database with the same query
↓
Database overload / slow response
Only one request recomputes; others wait for the result.
def get_data(key):
value = cache.get(key)
if value:
return value
with lock(f"lock:{key}"):
# Double-check: another thread may have computed it
value = cache.get(key)
if value:
return value
value = expensive_computation()
cache.set(key, value)
return value
Serve stale data while recomputing in the background.
value = cache.get(key)
if value is None:
value = expensive_computation() # Cache miss, compute fresh
elif value.is_stale:
queue_for_refresh(key) # Background task recomputes
return value # Serve stale data immediately
return value
Expire cache items probabilistically, spreading recomputation over time.
def should_recompute(expires_at):
time_until_expiry = expires_at - now()
probability = 1 - (time_until_expiry / total_ttl)
return random() < probability
Pre-populate cache before application handles requests.
def warm_cache():
popular_users = db.query("SELECT * FROM users ORDER BY followers DESC LIMIT 1000")
for user in popular_users:
cache.set(f"user:{user.id}", user, ttl=3600)
# Run on application startup
warm_cache()
Benefit: Avoid cold-start latency spikes.
Track:
| Metric | Formula | Target |
|---|---|---|
| Hit Rate | Hits / (Hits + Misses) | > 80% |
| Eviction Rate | Evictions / Time | Should be ~0 |
| Memory Usage | Used / Max | < 80% |
| P99 Latency | 99th percentile response time | < 10ms |
Alerts:
Get an email when I publish the next chapter. No spam — just new technical deep-dives.
Comments
Share feedback or questions about this blog post.
No comments yet. Be the first to share your thoughts.