Caching Strategies: A Practical Guide
Learn the main caching patterns — cache-aside, write-through, write-behind, and refresh-ahead — plus how to pick TTLs, invalidate stale data, and distribute caches across nodes.
Caching strategies determine how data flows between your cache and database. Cache-aside (lazy loading) checks the cache first and loads from the database on miss, populating the cache for next time. Write-through writes to both cache and database together. Write-behind writes to cache first and asynchronously persists to database. Refresh-ahead probabilistically refreshes cache before expiration based on access patterns. A cache that serves stale data is worse than no cache, and a cache needing constant babysitting is just overhead you do not need.
Caching Strategies: A Practical Guide
Introduction
Most applications have data that rarely changes but gets hit constantly. User profiles, product listings, config values, session data. Without caching, every request for this data pounds the database, even when nothing’s changed since last Tuesday.
The numbers tell the story:
| Approach | Typical Latency | Requests per Second (per node) |
|---|---|---|
| Database query | 5-50ms | 1,000-10,000 |
| Cache hit | 0.1-1ms | 100,000-1,000,000 |
| Cache miss (with cache) | 5-51ms | Same as database |
A cache that serves stale data is worse than no cache. And a cache that needs constant babysitting to stay valid is just overhead you do not need.
Core Concepts
These patterns describe how data flows between cache and application.
Read Caching Patterns
When an application reads data, the cache is the first stop. If the data is already cached and fresh, the read completes in sub-millisecond time. If not, the cache must either fetch it from the origin (database, API) or signal the application to do the fetch itself. The choice defines your read caching strategy.
The three most common read patterns differ in who handles the cache-miss logic and how stale data is treated:
- Cache-Aside (Lazy Loading). The application code checks the cache, loads from the database on a miss, then populates the cache. This is the simplest and most widely used pattern. The application stays in full control of when and how data enters the cache.
- Read-Through. The cache layer itself handles the miss by loading data from the database automatically. Application code just asks the cache for a key and receives data back, regardless of whether it was cached or freshly fetched.
- Invalidate-on-Read (Stale-While-Revalidate). The cache serves potentially stale data immediately while asynchronously checking the origin for fresher data. This trades a small window of staleness for consistently fast response times.
Each pattern shifts complexity between application and cache infrastructure. The sections below detail when and how to use each one.
Here is what separates these patterns: who triggers the database fetch on a cache miss. In cache-aside, your application code decides when to load from the database and explicitly writes the result back to the cache. In read-through, the cache library handles that round-trip internally, so your application code just reads a key and gets a value back. In invalidate-on-read, the cache serves stale data immediately while a background process fetches fresh data concurrently.
Cache-aside puts the most control in your hands. You decide when to populate the cache, what TTL to use, and how to handle errors during population. The catch is that every cache-miss path in your application needs to implement the same load-and-populate logic. Miss one code path, and you have inconsistent behavior.
Read-through is the most convenient from an application code perspective, but it requires a cache library that supports it natively. Spring Cache, Django’s cache framework, and groupcache all implement read-through. If your cache library does not support it, you end up building the same load-and-populate logic yourself, which cancels out the benefit.
Invalidate-on-read is the most complex to implement correctly but the most powerful for read-heavy workloads with unpredictable update patterns. The cache serves the stale value immediately, so users always get a fast response. The background revalidation happens asynchronously, so the first request after a TTL expiration does not pay the full miss penalty. This pattern works best when staleness tolerance is high (a few seconds of old data is acceptable) and when you want consistent response times even during cache repopulation events.
Invalidate-on-Read (Stale-While-Revalidate)
On each read, the cache checks whether the cached data is still fresh. This is typically done by comparing a version number, ETag, or timestamp against the origin. If the data is stale, the cache invalidates it and fetches fresh data from the origin before returning. This pattern is also known as stale-while-revalidate: serve the cached data immediately while asynchronously fetching an update if the data is past its freshness threshold.
This approach combines fast reads (cache hit returns immediately) with automatic background refresh for data that has changed. It works well when data changes unpredictably and you want to avoid write-time invalidation overhead.
Cache-Aside (Lazy Loading)
This is what most people mean when they say “caching.” Your application checks the cache first, loads from the database on a miss, then populates the cache for next time.
sequenceDiagram
participant Client
participant Cache
participant Database
Client->>Cache: GET user:123
Cache-->>Client: Cache miss
Client->>Database: SELECT * FROM users WHERE id = 123
Database-->>Client: User data
Client->>Cache: SET user:123 (ttl=3600)
Cache-->>Client: OK
Client->>Cache: GET user:123
Cache-->>Client: User data (cached)
Implementation:
def get_user(user_id):
# Try cache first
cache_key = f"user:{user_id}"
cached = redis.get(cache_key)
if cached:
return json.loads(cached)
# Cache miss - load from database
user = db.query("SELECT * FROM users WHERE id = ?", user_id)
# Populate cache with TTL
redis.setex(cache_key, 3600, json.dumps(user))
return user
Write operation:
def update_user(user_id, data):
# Update database first
db.execute("UPDATE users SET ... WHERE id = ?", user_id, data)
# Invalidate cache
redis.delete(f"user:{user_id}")
Pros:
- Simple to implement
- Cache only contains data that’s actually requested
- No cache stampede on startup (cold cache is expected)
- Easy to reason about
Cons:
- First request after cache miss is always slow
- Cache and database can temporarily diverge (eventual consistency)
- Three network round-trips on cache miss (check, read, write)
Use this when: reads dominate your workload and you can live with brief inconsistency.
Read-Through (Cache Enrichment)
Same idea as cache-aside, but the cache library handles the miss logic for you. You just ask the cache for data; it fetches from the database automatically if needed.
sequenceDiagram
participant Client
participant Cache
participant Database
Client->>Cache: GET user:123
Cache->>Cache: Check in-memory store
alt Cache miss
Cache->>Database: SELECT * FROM users WHERE id = 123
Database-->>Cache: User data
Cache->>Cache: Store in memory
end
Cache-->>Client: User data
Implementation with Redis:
def get_user_cached(user_id):
cache_key = f"user:{user_id}"
# Check if loader is registered
user = redis.get(cache_key)
if user:
return json.loads(user)
# Using Redis Functions or Lua script for atomic read-through
# This is handled by the cache layer itself
return None
Most caching libraries (like Spring Cache, Django’s cache framework, Go’s groupcache) implement read-through natively:
// Using groupcache (read-through implementation)
var db Database
sc := groupcache.NewGetter("http://cache-server/", &db)
func getUser(ctx context.Context, userID int64) (*User, error) {
var user User
key := fmt.Sprintf("user:%d", userID)
err := sc.Get(ctx, key, &user)
return &user, err
}
Pros:
- Cleaner application code
- Reduced latency on cache miss (cache fetches in parallel with other requests)
- Cache handles the fetch-and-store atomically
Cons:
- Less control over cache logic
- All caches must implement the same pattern
- Can mask cache behavior from developers
Use this when: you want caching to be infrastructure, not application logic.
Write Caching Patterns
On writes, the core question is: when does the database see the new data? The answer drives consistency, latency, and durability trade-offs. Write caching patterns describe how data moves from the application through the cache to the primary store.
Three main approaches exist, each with different guarantees:
- Write-Through. Every write goes to both cache and database before the operation returns. Strong consistency, higher write latency.
- Write-Behind (Write-Back). Writes go to the cache first, then are batched and flushed to the database asynchronously. Low latency, risk of data loss on cache failure.
- Refresh-Ahead. Writes update the cache directly; the cache proactively refreshes popular entries from the database before they expire. Good for read-heavy workloads with predictable hot data.
There is also Write-Around, where writes skip the cache entirely and go straight to the database. The cache is populated only on subsequent reads. This avoids polluting the cache with data that may never be read again, but it means the first read after a write always misses.
The right choice depends on whether your workload is read-heavy or write-heavy, how much inconsistency your application can tolerate, and what happens if the cache goes down before data reaches the database. The following sections cover each pattern in detail.
Write caching patterns matter most when your write-to-read ratio is high or when write latency is a user-facing concern. In read-heavy workloads (90% reads, 10% writes), cache-aside handles reads efficiently and write patterns matter less because writes are infrequent. In write-heavy workloads, how you handle the write path determines whether your cache stays useful or becomes a liability.
Write-through is the safest default when writes are non-trivial. Every write updates both cache and database before returning, so the cache never holds data that is newer than what is in the database. The cost is doubled write latency: you wait for both the cache write and the database write to complete. For applications where writes are user-initiated actions (submitting a form, saving a profile), this latency is usually acceptable because the user expects to wait. For high-frequency writes (sensor data, analytics events), write-through latency adds up quickly.
Write-behind assumes you can afford to lose writes. The cache acknowledges the write immediately, and the database update happens in the background. If the cache crashes before flushing, those writes are gone. This is acceptable for telemetry, logging, and analytics where losing a few data points is tolerable. It is not acceptable for financial transactions, inventory updates, or any data where every write must persist.
Refresh-ahead is misnamed as a write pattern. It is really a read optimization that happens to get triggered by writes. When you write data, the cache updates the entry. The refresh-ahead component proactively rewrites popular entries before they expire so that reads never wait on a cache miss. The write itself does not trigger the refresh, time does. This pattern is covered in detail in the Refresh-Ahead section below.
Write-Through
Every write goes to cache and database together. The operation doesn’t return until both succeed.
sequenceDiagram
participant Client
participant Cache
participant Database
Client->>Cache: SET user:123
Cache->>Database: UPDATE users SET ...
Database-->>Cache: OK
Cache-->>Client: OK
Implementation:
def update_user(user_id, data):
# Write to cache AND database
cache_key = f"user:{user_id}"
# Start transaction
db.execute("UPDATE users SET ... WHERE id = ?", user_id, data)
# Write-through to cache
redis.setex(cache_key, 3600, json.dumps(data))
return data
Pros:
- Strong consistency between cache and database
- Cache is always warm with latest data
- No cache invalidation logic needed
Cons:
- Write latency increases (two writes instead of one)
- Cache can be knocked out by write-heavy workloads
- Cache might be populated with data that’s never read
Use this when: consistency matters more than write speed and your writes are infrequent relative to reads.
Write-Behind (Write-Back)
You write to the cache and it batches the database writes to happen later, in the background.
sequenceDiagram
participant Client
participant Cache
participant Database
participant WriteBuffer
Client->>Cache: SET user:123
Cache->>WriteBuffer: Queue write
Cache-->>Client: OK (fast)
Note over WriteBuffer: Background worker
WriteBuffer->>Database: Batch UPDATE
Database-->>WriteBuffer: OK
Implementation:
import asyncio
from collections import deque
class WriteBehindCache:
def __init__(self, redis, db, batch_size=100, flush_interval=1.0):
self.redis = redis
self.db = db
self.write_queue = deque()
self.batch_size = batch_size
self.flush_interval = flush_interval
asyncio.create_task(self._flush_loop())
async def set(self, key, value):
self.redis.setex(key, 3600, json.dumps(value))
self.write_queue.append((key, value))
if len(self.write_queue) >= self.batch_size:
await self._flush()
async def _flush(self):
if not self.write_queue:
return
batch = []
while self.write_queue and len(batch) < self.batch_size:
batch.append(self.write_queue.popleft())
# Batch write to database
for key, value in batch:
self.db.execute(
"UPDATE users SET ... WHERE id = ?",
value['id'],
value
)
async def _flush_loop(self):
while True:
await asyncio.sleep(self.flush_interval)
await self._flush()
Pros:
- Very low write latency
- Batching reduces database load
- Cache handles burst writes gracefully
Cons:
- Risk of data loss if cache fails before flush
- Complexity in handling partial failures
- Cache and database can significantly diverge
- Harder to debug (writes happen asynchronously)
Use this when: you’re collecting metrics or events and losing a few writes won’t ruin your day.
Refresh-Ahead (Proactive Caching)
The cache automatically refreshes entries before they expire. Popular data stays perpetually warm, so users never hit a cache miss.
sequenceDiagram
participant Cache
participant Database
participant Refresher
Note over Cache: Entry TTL = 300s
Refresher->>Cache: Check TTL
Refresher->>Database: SELECT (background)
Refresher->>Cache: SET (reset TTL)
loop Every 60 seconds
Refresher->>Cache: Check popular entries
Refresher->>Database: Refresh if TTL < 60s
end
Implementation:
import time
from threading import Thread
class RefreshAheadCache:
def __init__(self, redis, db, ttl=300, refresh_threshold=0.8):
self.redis = redis
self.db = db
self.ttl = ttl
self.refresh_threshold = refresh_threshold
self.popular_keys = set()
# Background refresher thread
self.running = True
self.thread = Thread(target=self._refresh_loop)
self.thread.start()
def track_access(self, key):
"""Track frequently accessed keys"""
self.popular_keys.add(key)
def get(self, key):
value = self.redis.get(key)
if value:
self.track_access(key)
return json.loads(value)
return None
def _should_refresh(self, key):
"""Check if key needs proactive refresh"""
ttl = self.redis.ttl(key)
return ttl > 0 and ttl < (self.ttl * self.refresh_threshold)
def _refresh_loop(self):
while self.running:
for key in list(self.popular_keys):
if self._should_refresh(key):
# Refresh in background
data = self.db.query(
"SELECT * FROM users WHERE id = ?",
key.split(':')[1]
)
self.redis.setex(key, self.ttl, json.dumps(data))
time.sleep(10) # Check every 10 seconds
Pros:
- Eliminates cache miss latency for popular items
- Users never wait for cache to repopulate
- Smoother performance under varying loads
Cons:
- Wasted resources refreshing items not actually needed
- Complexity in tracking truly popular keys
- Risk of refreshing stale data
- Additional logic to determine refresh threshold
Use this when: you have a known set of hot data and read latency matters more than wasted cycles.
Topic-Specific Deep Dives
These sections dig into specific aspects of caching implementation and operations.
Memcached vs Redis: Making the Choice
Both Memcached and Redis serve as distributed caching layers, but they target different use cases. Understanding the trade-offs helps you pick the right tool.
| Feature | Memcached | Redis |
|---|---|---|
| Data structures | Key-value only | Strings, hashes, lists, sets, sorted sets, streams |
| Persistence | None (memory-only) | Optional RDB snapshots + AOF |
| Replication | Not built-in | Master-replica replication |
| Clustering | Consistent hashing client-side | Native cluster mode built-in |
| eviction policies | LRU, LFU, TTL | LRU, LFU, TTL + manual control |
| Memory efficiency | Simple slab allocator | More overhead per key |
| Use when | Simple caching, PHP/auto | Complex data, pub/sub, sorted sets, need persistence |
Memcached excels at: simple key-value caching where you just need to store serialized objects. Its memory efficiency and simplicity make it ideal for basic cache-aside patterns. Many PHP applications and frameworks default to Memcached for this reason.
Redis excels at: caching that requires data structures (like leaderboards with sorted sets, pub/sub for cache invalidation, or stream-based event queuing). Its native clustering and replication simplify operational complexity.
# Memcached: simple get/set
memcached.set(key, value, expire=3600)
value = memcached.get(key)
# Redis: richer operations
redis.set(key, value, ex=3600)
redis.zadd("leaderboard", {"user_id": score}) # Sorted set for rankings
redis.publish("invalidate", key) # Pub/sub for coordinated invalidation
For most caching scenarios, Redis wins because it reduces the number of systems you need to operate. But if you have a pure read-heavy workload with simple key-value requirements and memory efficiency is critical, Memcached is a valid choice.
When NOT to Cache
Caching is not always the answer. Here are scenarios where the complexity outweighs the benefits.
Do not cache when:
- Data changes on every request (no repeat reads to benefit from)
- Cache would consume more memory than the database itself (full table caching)
- Consistency requirements preclude staleness (financial transactions)
- Your database already handles your load comfortably (add complexity only when needed)
- Data is unique per request and never repeated (session data with per-user keys)
Signs your cache is not helping:
- Hit rate below 50% despite tuning
- Cache memory pressure causes constant evictions
- You spend more time managing cache invalidation than writing application code
- Cache failures cause more production incidents than database failures
Cache as a performance optimization, not a architectural necessity. If your database handles your load without caching, keep it simple.
CDN Caching for Static Assets
CDNs sit at the edge of your infrastructure, caching content close to users. Unlike application caches that handle dynamic data, CDNs typically handle static assets: images, CSS, JavaScript, fonts, videos.
CDN caching strategies differ from application caching:
| Aspect | Application Cache (Redis/Memcached) | CDN |
|---|---|---|
| Content type | Dynamic data, API responses | Static files (images, JS, CSS) |
| TTL range | Seconds to hours | Minutes to years |
| Invalidation | Event-driven or TTL | Purge API or TTL expiry |
| Cache key | Data-specific (user:123:profile) | URL-based (/assets/logo.png) |
| Geographic distribution | Limited to cache cluster location | Global PoPs near users |
Cache-Control directives every developer should know:
The Cache-Control header is the primary tool for controlling how CDNs and browsers cache your content. The directives below cover the patterns you will encounter most often in production:
-
max-age=<seconds>— Tells the cache how long to serve the asset before fetching a fresh copy. For static assets like images and fonts, values of31536000(1 year) are common. For dynamic content, shorter windows like300(5 minutes) or3600(1 hour) are more appropriate. -
no-cache— The asset may be served from cache, but it must be revalidated with the origin on each request. The cache sends a conditional request (withIf-None-MatchorIf-Modified-Since) and serves the cached version only if the origin confirms the content is still current. This is different fromno-store. -
no-store— Directs the cache to never store the response. Use this for sensitive data like user credentials or API responses that contain private information. Browsers and CDNs respect this directive strictly. -
private— Response may only be cached by the browser, not by shared caches like CDNs. Useful for authenticated API responses that should not sit in shared infrastructure. -
public— Response can be cached by both browsers and CDNs. Typically used for static assets that do not contain user-specific data. -
stale-while-revalidate=<seconds>— Serve the cached version while asynchronously fetching an update in the background. The number of seconds defines how long this staleness window lasts. For example,Cache-Control: public, max-age=3600, stale-while-revalidate=86400means the cache serves the asset for up to 1 day aftermax-ageexpires while revalidating in the background. This pattern smooths out traffic spikes during origin outages or TTL expiration events. -
immutable— The cached asset will never change. This directive tells the cache not not send any revalidation requests even if the user refreshes the page. Only use this when your build process embeds a content hash in the URL (likeapp.abc123.js). If the content can change, this directive causes stale data to be served indefinitely.
Most static asset CDNs default to max-age=31536000, immutable for fingerprinted assets and max-age=0, must-revalidate for HTML or other content that may change between deploys. One thing to watch: only use immutable when the URL changes whenever the content changes. If the content can change without the URL changing, immutable causes stale data to be served indefinitely. Otherwise, stick with a finite max-age and a stale-while-revalidate window to handle the transition gracefully.
Versioning Strategies
Versioning strategies let you cache assets aggressively by changing the cache key — usually the URL — when the content changes, instead of purging or invalidating the old entry. Every unique URL gets its own cache slot, so old entries stay cached and new entries start fresh with no coordination needed.
Three main approaches exist:
| Strategy | How It Works | Best For | Typical TTL |
|---|---|---|---|
| Content-hash URLs | Hash of file content embedded in filename | JS, CSS, images, fonts | 1 year (immutable) |
| Versioned paths | Version number in URL path (/v2/api) | API responses, SDK downloads | Hours to days |
| Timestamp-based | Build timestamp in query string or path | Fast iteration, dev builds | Minutes |
Content-hash URLs are the standard approach for static assets. Build tools like webpack, esbuild, and Vite generate names such as app.abc123.js where the hash reflects the file content. Since the URL changes when the content changes, you can set Cache-Control: public, max-age=31536000, immutable — the asset stays in CDN caches and browser caches indefinitely. A new deployment just serves a different URL. No manual invalidation, no cache-busting tricks.
Versioned paths work well when you need to keep old versions accessible. A URL like /api/v2/users can be cached separately from /api/v1/users, so you can roll out changes gradually without breaking existing clients. This pattern is common for API gateways and downloadable artifacts.
Timestamp-based strategies are the simplest to set up — append a build timestamp to the URL (/app.js?t=1700000000) on every deploy. The downside: every deploy invalidates every cache entry, even for files that did not change. Fine for development or low-traffic sites, but wasteful at scale.
The HTTP Cache-Control header is how you tell CDNs and browsers which strategy you are using. The examples below show the common patterns:
# Immutable assets: cache forever, change URL on deploy
Cache-Control: public, max-age=31536000, immutable
# Versioned asset: cache forever, change URL on deploy
# /app.abc123.js
# No caching (sensitive content)
Cache-Control: no-store, private
# Stale-while-revalidate (serve stale, update in background)
Cache-Control: public, max-age=3600, stale-while-revalidate=86400
CDN invalidation pitfalls:
- Purge is not instant. Most CDNs take 30 seconds to 5 minutes to propagate purges globally.
- Cache tags or content-type purging helps but is not universally supported.
- Versioned URLs (e.g.,
/app.abc123.js) beat cache invalidation for JavaScript/CSS updates.
<!-- Versioned asset: cache forever, change URL on deploy -->
<script src="/app.abc123.js"></script>
<!-- vs -->
<!-- Unversioned: requires CDN purge on every deploy -->
<script src="/app.js"></script>
When CDN alone is not enough: CDNs excel at caching static assets with long TTLs. But for dynamic content that changes frequently (like a news homepage), CDNs need help. Pattern: CDN edge caching with application-level cache invalidation via surrogate keys or tag-based purging. Cloudflare Workers or Fastly VCL can intercept requests and conditionally purge cache when the origin data changes.
Choosing the Right Strategy
| Strategy | Read Performance | Write Performance | Consistency | Complexity |
|---|---|---|---|---|
| Cache-Aside | Good (after miss) | Best | Eventual | Low |
| Read-Through | Good | Same as DB | Eventual | Low |
| Write-Through | Good | Good | Strong | Medium |
| Write-Behind | Good | Best | Eventual | High |
| Refresh-Ahead | Best | Same | Near-strong | High |
How to decide
Which latency matters more, reads or writes?
- Reads: cache-aside, read-through, or refresh-ahead
- Writes: write-behind or write-through
How synced do cache and database need to be?
- Tight consistency: write-through
- Eventual is fine: cache-aside or write-behind
What happens if the cache goes down before flushing?
- Can’t lose writes: write-through
- A few lost writes are okay: write-behind
Is access predictable?
- Unpredictable: cache-aside
- Known hot set: refresh-ahead
Cache Invalidation and TTL
Cache invalidation is the hardest part of caching. The right TTL strategy is equally critical for maintaining freshness vs. efficiency.
Time-Based Invalidation (TTL)
The simplest approach — entries expire after a fixed duration. You set a TTL when writing to the cache, and the cache automatically removes the entry when the timer expires. No application logic needed for expiration; the cache handles it.
# TTL-based invalidation
redis.setex(cache_key, 3600, value) # 1 hour TTL
When to use: Data that naturally becomes stale over time (user profiles, product prices, news articles). TTL works best when you can predict or tolerate a maximum staleness window. If a user profile update can take up to an hour to propagate, a 1-hour TTL is acceptable. If the same hour-long delay is unacceptable for a stock price, TTL alone is not sufficient.
Limitation: You must choose a TTL that balances freshness against load. Too short = cache thrashing, where entries expire before they are reused and every request hits the database. Too long = stale data, where users see outdated information for extended periods. TTL cannot respond to actual data changes, only to elapsed time. For data that changes unpredictably, TTL alone leaves you with either stale data or excessive database load.
Working with TTL in production: Redis TTL command returns -2 if the key does not exist and -1 if it exists but has no expiry. Check these return values in your application logic — a key that has already expired will return -2, not -1. Some teams set TTL on read rather than write, so the TTL clock resets every time the cache is accessed. This pattern (lazy TTL refresh) is common for data where access frequency indicates importance, but it can cause extremely popular keys to never expire, leading to staleness.
# Lazy TTL refresh: reset TTL on every access
def get_user(user_id):
cache_key = f"user:{user_id}"
cached = redis.get(cache_key)
if cached:
# Reset TTL on access (refresh the clock)
redis.expire(cache_key, 3600)
return json.loads(cached)
# ... load from database and populate
TTL handles expiration, not consistency. TTL-based invalidation guarantees that every entry will eventually expire. It does not guarantee that the cache reflects the current database state at any specific time. If your consistency requirements are tighter than your TTL window, combine TTL with event-driven invalidation.
Event-Based Invalidation (Cache Eviction on Write)
When data changes in the database, explicitly remove or update the corresponding cache entry. This is the application-level counterpart to TTL: instead of relying on time to expire entries, you delete them the moment the underlying data changes. The next read repopulates the cache with fresh data.
def update_user(user_id, data):
# Update database first
db.execute("UPDATE users SET ... WHERE id = ?", user_id, data)
# Invalidate cache entry
redis.delete(f"user:{user_id}")
# Optionally, immediately repopulate with fresh data
fresh_user = db.query("SELECT * FROM users WHERE id = ?", user_id)
redis.setex(f"user:{user_id}", 3600, json.dumps(fresh_user))
When to use: When you need immediate consistency on writes (write-through scenario). Event-based invalidation is the right choice when data changes are driven by your application code and you control all write paths. If every write path in your codebase goes through a single function or service, invalidation is straightforward to implement correctly.
The critical requirement: every write path must invalidate. The most common way event-based invalidation breaks is a missed write path. If your application has a bulk import feature, an admin panel, or a legacy code path that updates the database without going through the standard update function, those writes will produce stale cache entries. Before relying on event-based invalidation alone, audit your codebase for all database write paths.
# Common mistake: bulk import that bypasses cache invalidation
def bulk_import_users(csv_path):
for row in csv_reader:
# Direct database write — cache not invalidated
db.execute("INSERT INTO users ...", row)
# Result: all user cache entries are now stale
# Correct approach: invalidate after bulk operations
def bulk_import_users(csv_path):
for row in csv_reader:
db.execute("INSERT INTO users ...", row)
# After bulk import, clear all affected cache entries
redis.delete(*redis.keys("user:*"))
Two invalidation patterns: delete-only versus delete-and-repopulate. Delete-only (shown above) is simpler but leaves a cache miss window where the next read hits the database. Delete-and-repopulate immediately writes fresh data back to the cache, eliminating the miss window but adding latency to the write operation. Choose delete-only for write-heavy workloads where write latency matters more than a brief read miss. Choose delete-and-repopulate when read latency after writes is more important.
Limitation: Requires your application to remember to invalidate on every write path. Miss one path, and you have stale data. For this reason, event-based invalidation is almost always combined with TTL as a safety net. The TTL catches any invalidation paths that were missed; the event invalidation provides immediate consistency for the paths that are covered.
Event-Driven Invalidation (Pub/Sub)
Use a message queue or pub/sub system to propagate invalidation events across all cache nodes. When one application server updates the database, it publishes an invalidation message. All other application servers subscribe to these messages and delete the affected cache entries on their local caches. This keeps distributed caches synchronized without requiring all requests to hit a shared cache node.
# Publisher: when data changes
def update_user(user_id, data):
db.execute("UPDATE users SET ... WHERE id = ?", user_id, data)
# Publish invalidation event
redis.publish("cache:invalidate", f"user:{user_id}")
# Subscriber: on each application server
def subscribe_invalidation():
pubsub = redis.pubsub()
pubsub.subscribe("cache:invalidate")
for message in pubsub.listen():
if message["type"] == "message":
cache_key = message["data"]
redis.delete(cache_key)
When to use: Multi-server deployments where cache lives on application servers (local caches) and you need coordinated invalidation across all nodes. Pub/sub invalidation is the natural fit for two-tier cache architectures where each application server maintains its own local L1 cache. When the L1 cache on server A is invalidated, server B needs to know to invalidate its own L1 cache entry for the same user.
Why pub/sub alone is not enough: Redis pub/sub is fire-and-forget. If a subscriber is down or restarting when a message is published, it misses the message. There is no message persistence, no acknowledgment, no retry. During a rolling deployment or network blip, some servers will miss invalidation events and serve stale data until their TTL expires or they are restarted. This is why TTL is a mandatory safety net, not an optional extra.
Scaling the subscriber pattern: Every application server needs a subscriber process listening on the same channel. In a deployment with 100 application servers, every invalidation event generates 100 messages. Redis pub/sub is fan-out by design, but high-frequency invalidation events can create CPU pressure on the subscriber loops. For most production systems, a single shared distributed cache (L2) with pub/sub invalidation scaled across L1 nodes is more efficient than pub/sub fan-out to every node.
Alternatives for guaranteed delivery: If event delivery guarantees are critical, consider a message queue (Kafka, RabbitMQ) instead of Redis pub/sub. A message queue provides at-least-once delivery with persistence, so subscribers can replay missed messages after restarts. The tradeoff is added operational complexity — you now manage a message queue in addition to your cache infrastructure.
Hybrid Approach: TTL + Event Invalidation
The most robust strategy combines TTL (safety net) with event invalidation (specificity). Event invalidation handles the common case: when data changes, the cache is immediately updated. TTL handles the failure case: when an invalidation event is missed, the entry eventually expires regardless. Neither alone is sufficient; together they provide both immediate consistency and failure safety.
def get_user(user_id):
cache_key = f"user:{user_id}"
cached = redis.get(cache_key)
if cached:
return json.loads(cached)
user = db.query("SELECT * FROM users WHERE id = ?", user_id)
# Set TTL as safety net (e.g., 1 hour)
redis.setex(cache_key, 3600, json.dumps(user))
return user
def update_user(user_id, data):
db.execute("UPDATE users SET ... WHERE id = ?", user_id, data)
# Immediate invalidation via event (specific)
redis.publish("cache:invalidate", f"user:{user_id}")
# TTL still acts as safety net if event is missed
Why both together: Event invalidation provides immediate consistency when it works, but it relies on every write path being covered and every subscriber being reachable. TTL provides eventual consistency regardless of application logic or infrastructure failures, but it cannot bound the staleness window. The hybrid approach uses event invalidation to minimize the inconsistency window for the happy path, while TTL ensures that even worst-case scenarios (missed events, crashed subscribers, network partitions) resolve within a bounded time.
Implementation priority: Set your TTL to the maximum acceptable staleness window for your data. If a user profile can be stale for up to 1 hour without causing problems, set TTL to 3600 seconds. Your event invalidation should attempt to invalidate within milliseconds of the write, so the actual inconsistency window is typically seconds rather than hours. The TTL catches the outliers.
The failure sequence TTL prevents: Without TTL as a safety net, a missed invalidation event leaves stale data indefinitely. Consider a scenario where a subscriber process crashes during a deployment and is not restarted for 3 hours. Without TTL, any database updates during that window produce cache entries that never get invalidated. With a 1-hour TTL, the worst-case staleness is 1 hour.
Cache Invalidation in Distributed Systems
In distributed cache setups, invalidation becomes more complex because multiple cache nodes might hold the same key. In a cache-aside pattern with consistent hashing, each key lives on exactly one node, so you can compute which node holds the key and invalidate it directly. But in configurations where multiple application servers maintain local caches, or where data is replicated across nodes, you need coordinated invalidation across all nodes that might hold a copy.
# Using Redis SCAN to find and delete all copies of a key across nodes
def invalidate_key_across_cluster(key_pattern):
"""
Invalidate all keys matching a pattern across the cluster.
Use with caution — expensive operation.
"""
cursor = 0
while True:
cursor, keys = redis.scan(cursor, match=key_pattern, count=100)
if keys:
redis.delete(*keys)
if cursor == 0:
break
# Example: invalidate all user session keys for a specific user
invalidate_key_across_cluster(f"session:*:user:{user_id}")
Why SCAN is expensive: The Redis SCAN command iterates through all keys in the database, not just keys matching your pattern. Even with a cursor-based approach, running SCAN with a pattern filter on a large database is an O(N) operation that can degrade cache performance if run frequently. In production with millions of keys, scanning the entire keyspace to invalidate a handful of entries is impractical. This is why consistent hashing — where you can compute which node holds a specific key — is preferred for targeted invalidation.
Direct invalidation with consistent hashing: When you use consistent hashing, each key maps to a specific node based on its hash. To invalidate a key, compute its hash, determine which node owns it, and send the delete command directly to that node. No scanning required. This is the most efficient invalidation approach for distributed caches that support it.
# Direct invalidation without SCAN
def invalidate_key(key):
# Consistent hash determines which node owns this key
node = consistent_hash.get_node(key)
# Send delete directly to the owning node
node.delete(key)
# For a user profile update, invalidate only the key on the owning node
invalidate_key(f"user:{user_id}")
Pattern-based invalidation usually means your key design is too coarse: If you find yourself needing to invalidate keys by pattern (all user sessions, all expired tokens), consider restructuring keys so that individual invalidation is sufficient. For example, instead of storing all of a user’s sessions under session:*:user:{user_id}, store each session as session:{session_id} and maintain a set of active session IDs per user. When you need to invalidate all of a user’s sessions, delete the set (fast) rather than scanning for matching keys (slow).
TTL Selection Guide
Choosing the right TTL is a balancing act between data freshness, cache efficiency, and database load.
TTL Selection Framework
Choosing the right TTL requires answering three questions about your data. The answers determine a range, and then you tune within that range based on observed cache hit rates and staleness complaints.
1. How stale can this data be?
The staleness tolerance is the maximum time users can tolerate seeing outdated information. This is a product requirement, not a technical one. Ask your product team: how long can a user see an old price before it becomes a problem? The answer directly maps to your TTL.
| Data Type | Staleness Tolerance | Suggested TTL |
|---|---|---|
| Real-time prices | Seconds | 30-60 seconds |
| Social media posts | Minutes | 5-15 minutes |
| User profiles | Minutes to hours | 15-60 minutes |
| Product catalog | Hours | 1-24 hours |
| Static config | Hours to days | 1-24 hours |
| Session data | Duration of session | 24 hours |
Note that the suggested TTL is typically shorter than the staleness tolerance. A user might tolerate a 5-minute-old price, but your product team sets that tolerance assuming the cache is working correctly. If invalidation events are missed, TTL must pick up the slack. Setting TTL to 80% of the staleness tolerance provides a safety margin.
2. What is the cost of a cache miss vs stale data?
The miss penalty determines whether longer TTLs are safe. A high miss penalty (expensive database query, high user traffic) argues for longer TTLs to maximize cache hit rate. A low miss penalty (fast query, low traffic) gives you flexibility to use shorter TTLs.
- Miss-costly, stale-tolerant: longer TTLs work fine (config, user preferences)
- Miss-costly, stale-intolerant: use shorter TTLs + event invalidation (prices, inventory)
- Miss-cheap, stale-tolerant: shorter TTLs are fine (view counts, trending topics)
The interaction between miss cost and staleness tolerance matters. For data where miss-costly and stale-intolerant both apply (financial prices, inventory levels), TTL alone is never sufficient. You need event-driven invalidation regardless of TTL settings.
3. How does access pattern decay?
Data that spikes in popularity then drops off (social posts, news) needs shorter TTLs than evergreen content (documentation, product specs). The reason is working set dynamics: if a social post gets1 million reads in the first hour and 100 reads per day thereafter, a long TTL keeps the post cached during the tail period when it is no longer worth the memory. A shorter TTL lets the cache evict the cooling post and reuse that memory for trending content.
For access patterns you can predict, adjust TTL at write time based on content type. For unpredictable patterns, monitor access distribution and tune TTL based on observed eviction rates. If you see high eviction rates on content that should still be hot, your TTL is too short. If you see low hit rates on content that should be hot, your TTL might be too long or your working set larger than cache capacity.
TTL Jitter (Preventing Thundering Herds)
If all cache entries expire at the same time, you get a thundering herd when they all expire. Imagine a popular feature flag that 10,000 users access per second, all cached with a 1-hour TTL that was set during a deployment60 minutes ago. When the TTL expires, all 10,000 concurrent requests hit the database simultaneously, overwhelming it before the cache repopulates. TTL jitter prevents this by randomizing expiration times so that entries expire spread across time rather than all at once.
import random
def set_with_jitter(key, value, base_ttl):
"""
Set cache with randomized TTL to prevent synchronized expiration.
Jitter is +/- 10% of base TTL.
"""
jitter = base_ttl * 0.1
actual_ttl = base_ttl + random.uniform(-jitter, jitter)
redis.setex(key, int(actual_ttl), value)
# Usage
set_with_jitter("user:123", user_data, base_ttl=3600) # 3240-3960 seconds
How much jitter: A common rule is +/- 10% of the base TTL. For a 1-hour TTL, this spreads expiration across a 12-minute window. The goal is to ensure that at any given moment, only a fraction of entries are near expiration. If your cache has1 million entries and you set TTLs with 10% jitter, approximately 100,000 entries will be in their final 10% of life at any time, rather than all 1 million expiring simultaneously.
Jitter is especially important for: batch-loaded data (all entries set at the same time during a job), data loaded during deployments (all TTLs set at deployment time), and entries with identical or correlated access patterns (all users who viewed a specific page). In each case, synchronized expiration is likely without jitter.
Jitter cannot fix all stampedes: Jitter spreads the load from synchronized expiration, but it does not eliminate the thundering herd problem entirely. When a cache entry expires and multiple requests arrive before the first request repopulates the cache, you still get a pile-up. For stampede protection during the repopulation window, you need request coalescing (locks or semaphores) in addition to jitter. Jitter handles the case where multiple entries expire simultaneously; request coalescing handles the case where multiple requests arrive for the same expired entry simultaneously.
TTL Tiering
For the same data, consider storing multiple copies at different TTLs for different freshness requirements. TTL tiering is useful when different consumers of the same data have different staleness tolerances. A web page might tolerate a 5-minute-old product price for regular visitors, but an admin dashboard needs the latest data. Instead of choosing one TTL for all consumers, you maintain multiple cache entries with different freshness guarantees.
def cache_user_profile(user_id):
cache_key = f"user:{user_id}"
# Fresh copy: short TTL
fresh = redis.get(f"{cache_key}:fresh")
if not fresh:
fresh = db.query("SELECT * FROM users WHERE id = ?", user_id)
redis.setex(f"{cache_key}:fresh", 300, json.dumps(fresh)) # 5 min
# Stale copy: long TTL (fallback)
stale = redis.get(f"{cache_key}:stale")
if not stale:
stale = fresh # Initial population
redis.setex(f"{cache_key}:stale", 86400, json.dumps(stale)) # 24 hours
return fresh if fresh else stale
The tiering pattern in practice: When a request comes in, try the freshest tier first. If it exists and is not expired, serve it. If not, try the stale tier. Only on a complete miss do you hit the database. This gives you fast paths for latency-sensitive reads while maintaining a fallback for staleness-sensitive scenarios.
Tradeoffs of TTL tiering: The memory cost is N times the value size, where N is the number of tiers. For large objects, this can be significant. Additionally, the stale tier will always be at least as stale as the fresh tier, so serving from the stale tier means accepting the maximum staleness window. TTL tiering works best when the fresh tier has a very short TTL (seconds to minutes) and the stale tier acts as a long-TTL safety net, so the memory overhead is modest relative to the staleness guarantee.
When TTL tiering is overkill: If all consumers of your data have the same staleness tolerance, a single TTL is simpler and more memory-efficient. TTL tiering adds code complexity and memory overhead only when different consumers genuinely need different freshness guarantees. For most caching scenarios, a single well-chosen TTL with event invalidation is sufficient.
Dynamic TTL Based on Data Characteristics
Some data has variable freshness based on its nature. Use dynamic TTLs that adapt to content type and context. Static documentation can be cached for days; breaking news should be cached for seconds. Dynamic TTL is the mechanism that lets you express these different requirements without separate caching infrastructure.
def get_dynamic_ttl(data_type, data_age_hours=0):
"""
Return appropriate TTL based on data type and age.
"""
base_ttls = {
"breaking_news": 30, # 30 seconds
"sports_scores": 60, # 1 minute
"product_price": 300, # 5 minutes
"blog_post": 1800, # 30 minutes
"documentation": 86400, # 24 hours
}
base = base_ttls.get(data_type, 3600)
# Reduce TTL for rapidly changing data
if data_age_hours < 1:
return base // 2 # Halve TTL for fresh content
return base
Content-type-based TTL: The simplest form of dynamic TTL assigns different TTLs based on data category. Breaking news gets a30-second TTL because it changes frequently and staleness is highly visible. Documentation gets a 24-hour TTL because it rarely changes and database hits are acceptable. The mapping from content type to TTL should be stored in configuration, not hardcoded, so it can be tuned without code changes.
Age-based TTL adjustments: Fresh content is more likely to change than old content. A blog post that was published 10 minutes ago is more likely to be edited than one published 2 years ago. Age-based TTL reduction halves the TTL for content under a certain age threshold, providing more responsiveness to updates on recent content while maintaining longer TTLs for stable content.
Traffic-based TTL: Another approach is to adjust TTL based on access frequency. Popular content that is accessed thousands of times per minute can afford a shorter TTL because the cache is repopulated frequently and staleness windows are brief. Less popular content might benefit from longer TTLs because cache misses are rarer and the staleness impact is lower. Traffic-based TTL requires access tracking but can optimize for both freshness and cache efficiency.
The key insight: There is no single correct TTL for data that changes in nature over time. Dynamic TTL lets your caching layer express “this data is fresh for X seconds” as a property of the data itself, not a global configuration. As your data changes character — a breaking news story becomes historical context, a new product launches then matures — the TTL should adapt.
Distributed Cache Patterns
When a single cache instance cannot handle your load, distribute the cache across multiple nodes.
Consistent Hashing
Consistent hashing maps keys to cache nodes based on key hash values, minimizing remapping when nodes are added or removed. The core idea is to treat the hash space as a ring: each node occupies multiple points on the ring (virtual nodes), and each key maps to the first node found when walking clockwise from the key’s hash position. When a node is added or removed, only the keys that mapped to that region of the ring remap; other keys stay on their current nodes.
import hashlib
class ConsistentHash:
def __init__(self, nodes):
self.ring = {}
self.sorted_keys = []
for node in nodes:
self._add_node(node)
def _add_node(self, node):
for i in range(100): # Virtual nodes for better distribution
key = hashlib.md5(f"{node}:{i}".encode()).hexdigest()
self.ring[key] = node
self.sorted_keys.append(key)
self.sorted_keys.sort()
def get_node(self, key):
key_hash = hashlib.md5(key.encode()).hexdigest()
for sorted_key in self.sorted_keys:
if key_hash <= sorted_key:
return self.ring[sorted_key]
return self.ring[self.sorted_keys[0]]
# Usage
ch = ConsistentHash(["cache-1", "cache-2", "cache-3"])
node = ch.get_node("user:123") # Always returns same node for same key
Why virtual nodes matter: Without virtual nodes, each physical node occupies a single point on the ring. This causes uneven distribution because the hash of a node name does not correlate with its capacity or position relative to keys. Virtual nodes (100 per physical node in the example above) give each physical node 100 points on the ring, smoothing the distribution. When a node fails, its 100 virtual node positions are distributed across the remaining nodes, so no single node absorbs a large number of additional keys.
The remapping guarantee: When you add or remove a node, only K/n keys remap, where K is total keys and n is nodes. For a cache with 1 million keys and 10 nodes, adding an 11th node causes approximately 91,000 keys to remap (about 9% of the total). Without consistent hashing, a traditional hash modulo approach would remap all keys. This bounded remapping is what makes horizontal scaling practical — you can add cache nodes without invalidating the entire cache.
When consistent hashing is not needed: If your cache is a single instance or you are using a managed cache service that handles distribution internally (like ElastiCache cluster mode), you do not need to implement consistent hashing yourself. Consistent hashing becomes relevant when you are building a custom distributed cache layer or when you need application-level routing to specific cache nodes.
Cache Sharding by Entity
Instead of distributing keys at random, shard by entity type so related data stays together. Cache sharding by entity ensures that all data for a specific user, product, or order lives on the same cache node. This enables efficient multi-key operations — when you need a user’s profile, preferences, and recent activity, a single cache node can serve all three without cross-node requests.
def get_shard(cache_key):
"""
Shard by entity type to keep related data together.
"""
# Extract entity type from key
entity_type = cache_key.split(":")[0] # "user", "product", "order"
# Hash the entity type for distribution
type_hash = hashlib.md5(entity_type.encode()).hexdigest()
# Map to shard
shard_index = int(type_hash, 16) % NUM_SHARDS
return f"cache-shard-{shard_index}"
# Route requests to appropriate shard
def cache_get(cache_key):
shard = get_shard(cache_key)
return redis_shards[shard].get(cache_key)
The multi-key operation benefit: Suppose a user profile page needs to display the user’s name, email, and account status. With entity sharding, all three values are on the same shard, so a single MGET command retrieves them in one network round-trip. With random distribution, the three keys might be on three different nodes, requiring three separate network calls. For read-heavy workloads with multi-key access patterns, entity sharding reduces network round-trips significantly.
Tradeoff: hot spots: Entity sharding distributes load by entity type, not by access frequency. If one user is orders of magnitude more active than others (a celebrity user, a high-frequency trading account), that user’s shard receives disproportionate traffic. Consistent hashing distributes load more evenly across nodes but loses the multi-key colocation benefit. In practice, hot spots are managed by subdividing hot entities across multiple shards or by adding replica nodes for read distribution.
When entity sharding makes sense: Use entity sharding when your access patterns naturally group data by entity and when multi-key operations are frequent. User data (profile + preferences + history), order data (header + line items + shipping info), and product data (catalog + pricing + inventory) are all candidates. If your access patterns are single-key lookups with no natural grouping, consistent hashing is simpler and provides better load distribution.
Replication with Read Replicas
For read-heavy workloads, add replica nodes that handle read traffic while the primary handles writes. Redis replication is asynchronous: when you write to the primary, the primary acknowledges the write immediately after persisting it locally, then streams the write to replicas in the background. Replicas apply writes in the order they were received on the primary, but there is a lag window where a replica might serve stale data.
# Write to primary
def cache_set(key, value):
redis_primary.setex(key, 3600, value)
# Replicate asynchronously to read replicas
redis_replicas.each { |r| r.setex(key, 3600, value) }
# Read from replica (randomly selected)
def cache_get(key):
replica = random.choice(redis_replicas)
return replica.get(key)
The replication lag tradeoff: Redis replication is single-threaded on the replica side — each replica applies writes sequentially. Under heavy write load, the replica lag can grow to seconds or even minutes. For a cache, this means reads from replicas might return data that is minutes old. Whether this is acceptable depends on your staleness tolerance. Most caching scenarios where replicas are used for read scaling can tolerate replica lag because the data being cached is already expected to be somewhat stale.
When to use read replicas for caching: Replicas make sense when read volume far exceeds write volume (a common pattern for caching). If your cache receives100,000 reads per second but only 1,000 writes per second, adding replica nodes lets you scale read throughput linearly with the number of replicas while the primary handles all writes. The staleness introduced by replication lag is typically no worse than the staleness already inherent in TTL-based caching.
Monitoring replication lag: In Redis, INFO replication shows master_repl_offset on the primary and slave_repl_offset on replicas. The difference between these offsets is the replication lag in bytes. Set up alerts if the lag exceeds your staleness tolerance. A lag of 1MB sounds small until you realize your write rate is 10MB per second, meaning the replica is 100ms behind. At higher write rates, the same1MB lag could represent seconds of staleness.
Avoiding replica lag pitfalls: If your write pattern is burst-heavy (periodic batch jobs, scheduled reports), replication lag will spike during bursts and slowly catch up during quiet periods. During the burst, replica-stale reads increase. To mitigate this, consider replica lag thresholds that route reads back to the primary when lag is excessive, or use the replica’s WAIT command to acknowledge writes only after a configurable number of replicas have applied them.
Multi-Tier Caching
Deploy a local (L1) in-memory cache in front of a distributed (L2) cache. The L1 cache is typically an in-process data structure (like Caffeine or an LRUCache) that lives inside your application process. It serves requests in microseconds with no network overhead, but it is local to each application instance and loses all data on restart. The L2 cache is a shared distributed cache like Redis that survives application restarts but requires a network round-trip.
import functools
from threading import Lock
class TwoTierCache:
def __init__(self, local_cache, redis_cache):
self.local = local_cache # e.g., LRUCache from cachetools
self.redis = redis_cache
self.local_lock = Lock()
def get(self, key):
# Try L1 first (local, ultra-fast)
value = self.local.get(key)
if value is not None:
return value
# L1 miss — try L2 (distributed)
value = self.redis.get(key)
if value is not None:
# Populate L1 for next request
with self.local_lock:
self.local[key] = value
return value
return None
def set(self, key, value, ttl=3600):
# Write to both tiers
self.redis.setex(key, ttl, value)
with self.local_lock:
self.local[key] = value
def invalidate(self, key):
self.redis.delete(key)
with self.local_lock:
self.local.pop(key, None)
Why two tiers: The L1 cache serves requests that hit the ultra-hot set in microseconds. YouTube reports that their per-machine L1 cache achieves 50-60% hit rate for shared popular content — users on the same machine accessing the same popular videos. This means half of all cache requests never touch the network. For the other half, the L2 distributed cache handles the warm working set that does not fit in local memory. The combination of L1 and L2 typically achieves 95%+ hit rate with modest L2 infrastructure.
L1 cache sizing: L1 cache should be sized to hold the top N hot keys that are accessed frequently enough to justify local memory. A good starting point is 1-10% of application heap memory. Monitor L1 hit rate separately from L2 hit rate. If L1 hit rate is below 30%, your L1 is too small or your working set is too large for local memory. If L1 hit rate is above 80%, you might be able to reduce L1 size and free memory for application use.
Invalidation complexity: Multi-tier caching introduces a new invalidation challenge: when data changes, you must invalidate both L1 and L2. If you invalidate L2 but not L1, stale data lives in L1 until the entry is evicted or the application restarts. If you invalidate L1 but not L2, the next L1 miss repopulates from stale L2 data. The invalidate method above handles this correctly by deleting from both tiers, but it requires all write paths to use the cache layer rather than bypassing it.
YouTube’s architecture uses exactly this pattern: L1 per-machine cache handles the ultra-hot set, L2 distributed cache handles warm data, and the database handles cold data. Most production systems should target a two-tier design before adding additional tiers or CDN.
Case Study: YouTube’s Cache Hierarchy
YouTube’s caching infrastructure is one of the most studied in the industry. Their approach uses multiple cache layers: L1 (in-memory, per-machine), L2 (distributed cache), and CDN at the edge.
YouTube’s L1 cache is a small in-memory cache on each application server. It handles the most frequently accessed items — popular videos, trending content. L1 hit rate alone is often 50-60% because many users on the same machine access the same popular content.
The L2 distributed cache (originally Memcached, later moved to custom infrastructure) handles cache misses from L1. L2 is sharded across many machines to provide petabyte-scale capacity. Cache misses from L2 go to storage (BigTable).
The CDN handles the edge, serving popular content from points of presence close to users. YouTube’s CDN cache hit rate is over 90% for video streaming — once a video becomes popular, it propagates to CDN PoPs and subsequent requests rarely hit origin.
The lesson: YouTube does not rely on a single cache tier. They use L1 to handle the ultra-hot set with extremely low latency, L2 for the warm cache, and CDN for the long tail of popular-but-not-ultra-popular content. Most companies should design for two tiers (local cache + distributed cache) before adding a CDN.
Case Study: Twitter’s Cache Warming Strategy
Twitter has a unique caching challenge: events (tweets, likes, follows) have a short window of high read traffic, then traffic drops off a cliff. A tweet from a celebrity gets millions of reads in the first hour, then readership drops to hundreds per day.
Twitter’s solution is aggressive cache warming: when a tweet is published, Twitter pushes it into the timelines of active followers’ caches rather than waiting for cache misses. This is the fanout-on-write pattern — write to caches at publish time rather than computing at read time.
The tradeoff is write amplification. Every tweet from a celebrity with 10 million followers requires 10 million cache writes. Twitter manages this by limiting fanout to active users only and using hybrid push/pull for lower-activity accounts. Inactive users’ timelines are computed on read from the tweet author’s tweet store.
The operational lesson: cache warming trades write amplification for read latency. For content with rapid decay in read traffic (news, social posts, live events), warming the cache at write time reduces read latency at the cost of higher write overhead. For evergreen content, cache-aside with long TTLs is simpler and more efficient.
Capacity Estimation: Cache Size vs Hit Rate
The relationship between cache size and hit rate is not linear. Adding more cache memory gives diminishing returns beyond a certain point. Understanding this relationship lets you right-size your cache infrastructure and avoid over-provisioning.
The working set model: your hit rate depends on how much of your frequently-accessed data fits in cache. If 80% of your requests hit 20% of your data, and that 20% fits in cache, you can achieve 95%+ hit rate with a relatively small cache. If access is uniformly distributed across all keys, even a large cache provides modest hit rates because every key competes equally for space. Before sizing your cache, analyze your access distribution — a cache sized for uniform access will be dramatically over-provisioned if your actual access pattern follows a power law.
The formula for estimating required cache size: working_set_bytes = unique_keys_per_second * avg_value_size * avg_ttl_seconds. If you have 10,000 requests per second, average value is 1KB, and you want a 5-minute TTL window, your working set is 10,000 1,000 300 = 3GB minimum for a fully-utilized cache before evictions. In practice, you need 1.5-2x that because LRU/LFU policies do not perfectly track the working set. They approximate it based on recency or frequency, which can lag behind sudden changes in access patterns, especially during traffic spikes or viral content events.
The hit rate curve: start at 0% hit rate with no cache, rapid climb as cache grows to cover the hot working set, then diminishing returns as cache size exceeds working set. Plot your hit rate against cache size to find the knee of the curve — the point where adding more cache stops helping significantly. This is your target cache size. Most production workloads show the knee somewhere between 50-70% of the working set fitting in cache, beyond which additional memory yields minimal hit rate improvement.
For cache-aside specifically, the miss penalty matters more than raw hit rate. A cache miss does a full database round-trip. If your database latency is 10ms and cache latency is 0.5ms, each miss costs 9.5ms extra. At 99% hit rate, only 1% of requests pay the miss penalty. At 95% hit rate, 5% pay it — a 5x increase in slow queries. The implication: a cache that achieves 95% hit rate but sits at 80% memory utilization is a better investment than one at 99% hit rate at 100% memory (where evictions are frequent). Monitor both hit rate and memory utilization together.
Sizing example: Suppose your application has 1 million unique keys with an average value size of 2KB. Your access pattern follows a power law: the top 1% of keys (10,000 keys) account for 80% of traffic. The effective working set is 10,000 _ 2KB = 20MB. A cache sized at 20-40MB captures the hot working set and achieves 80%+ hit rate. Sizing at 2GB (the full dataset) might push hit rate to 95%, but costs50x more memory for a15% hit rate improvement. The right size depends on the cost of misses versus the cost of memory.
Monitoring and Operations
Observability Checklist
Monitor these metrics and set up alerts for production cache health.
Metrics to Track
- Hit Rate:
hits / (hits + misses)- should stay above 80-90% for well-tuned caches - Memory Usage:
used_memory / maxmemory- alert at 70%, critical at 80% - Eviction Count:
evicted_keys- indicates memory pressure - Connection Count:
connected_clients- sudden drops indicate connection issues - Command Latency: P50, P95, P99 for GET/SET operations
- Replication Lag: For replicated setups, lag should stay below 100ms
Logs to Capture
# Log cache operations for debugging
import structlog
logger = structlog.get_logger()
def get_user(user_id):
cache_key = f"user:{user_id}"
start = time.time()
cached = redis.get(cache_key)
if cached:
logger.info("cache_hit", key=cache_key, latency_ms=(time.time() - start) * 1000)
return json.loads(cached)
# Cache miss - this should be rare in production
logger.warning("cache_miss", key=cache_key)
user = db.query("SELECT * FROM users WHERE id = ?", user_id)
redis.setex(cache_key, 3600, json.dumps(user))
return user
Alert Rules
# Prometheus alert rules for Redis
- alert: CacheHitRateLow
expr: redis_keyspace_hits_total / (redis_keyspace_hits_total + redis_keyspace_misses_total) < 0.8
for: 5m
labels:
severity: warning
annotations:
summary: "Cache hit rate below 80%"
- alert: CacheMemoryExhausted
expr: redis_memory_used_bytes / redis_memory_max_bytes > 0.8
for: 2m
labels:
severity: critical
annotations:
summary: "Cache memory above 80% capacity"
Security Checklist
Cache security is often overlooked until a breach happens.
- Never expose Redis/Memcached directly to the internet - Bind to localhost or private network only
- Use authentication - Redis
requirepassor Memcached SASL authentication - Enable TLS - For connections crossing network boundaries
- Validate key namespaces - Use prefixes like
app:env:table:to prevent key collisions - Sanitize cache keys - User input should never become cache keys without validation
- Implement rate limiting - Prevent cache exhaustion attacks
- Audit cache access - Log who accessed what, especially for sensitive data
- Never cache sensitive data - PII, passwords, tokens, payment info should never enter the cache
# Redis secure configuration
bind 127.0.0.1 -::1
requirepass your-strong-password-here
tls-replication yes
tls-auth-clients no
Trade-off Analysis
No single caching strategy wins in every scenario. Cache-aside works well for read-heavy workloads where brief staleness is acceptable, but it leaves you exposed if writes must be immediately consistent. Write-through keeps cache and database in lockstep but doubles write latency. Write-behind batches writes for speed at the cost of durability. Refresh-ahead eliminates miss penalties for hot data but burns CPU cycles refreshing entries nobody asked for.
The four dimensions below capture the major trade-offs. Use them as a decision framework: start by asking which dimension matters most for your workload, then pick the strategy that optimizes for that axis. Most teams start with cache-aside because it is the easiest to debug, and add complexity only when measurements show a specific problem.
| Dimension | What It Measures |
|---|---|
| Consistency | How long cache and database can diverge |
| Performance | Read and write latency characteristics |
| Complexity | Code and operational burden |
| Durability | Risk of data loss on cache failure |
The sections below dig into each dimension in detail.
Production Failure Scenarios
Understanding what fails and how to recover is critical for production caching systems.
Cache Node Failure
When a cache node goes down, all requests directly hit the database, potentially causing cascade failure.
Mitigation:
- Use connection pooling with automatic retry
- Implement circuit breaker pattern
- Use replica nodes for read failover
Memory Exhaustion
When cache memory is exhausted, eviction kicks in aggressively and hit rate drops to 0%.
Mitigation:
- Monitor memory usage and set appropriate
maxmemorylimits - Alert at 70% threshold
- Implement proper TTL policies
Network Partition
When network connectivity between application and cache fails, requests hang or timeout.
Mitigation:
- Set reasonable socket timeouts (100-500ms)
- Configure fail-fast behavior to fall back to database
- Use connection pooling with health checks
Thundering Herd on Restart
When cache restarts, all clients hit the database simultaneously.
Mitigation:
- Pre-warm cache on restart
- Use staggered TTLs with jitter
- Implement request coalescing (semaphores or locks)
Cache Credential Rotation
Cache credentials — passwords, SASL tokens, TLS certificates — need periodic rotation like any other secret. The challenge is that cache clients must be able to reconnect seamlessly when credentials change, without dropping in-flight requests or causing authentication storms.
Why cache credential rotation is tricky:
Cache clients maintain persistent connections to cache nodes. When credentials change, those existing connections become invalid. If every client reconnects simultaneously, you get a thundering herd of reconnection attempts that can overwhelm the cache or trigger rate limiting.
The rotation procedure:
-
Deploy new credentials to all cache nodes first. Most cache servers support dual authentication — accepting both old and new credentials during a transition window. Redis
requirepasscan be updated without restart in newer versions, and Memcached supports SASL dual-mode during rotation. -
Update application credential configuration. Push the new credentials to your application configuration (environment variables, secrets manager) but do not restart applications yet.
-
Enable new credentials on application side. Use connection pool reconfiguration to pick up the new credentials without a full restart. Most Redis client libraries support reconnection with new auth parameters when connections are recycled.
-
Wait one full connection cycle. Give existing connections time to naturally expire or be recycled before decommissioning old credentials.
-
Remove old credentials from cache nodes. Once all application instances have cycled their connections, revoke the old credential from the cache server.
Connection pooling with lazy reconnection:
With lazy reconnection, when a connection fails authentication, the pool marks it as invalid and retries with fresh credentials on the next request. This spreads reconnection attempts over time rather than bunching them up:
# Redis connection pool with lazy reconnection
from redis import ConnectionPool
pool = ConnectionPool(
host='redis-host',
port=6379,
password=None, # Updated via config reload
max_connections=50,
retry_on_timeout=True,
health_check_interval=30
)
def reload_credentials(new_password):
# Signal pool to use new credentials on next connection
pool.disconnect()
pool.connection_kwargs['password'] = new_password
Credential rotation anti-patterns:
- Restarting all application instances simultaneously — This forces every connection to drop and reconnect at once, creating a thundering herd.
- Rotating during peak traffic — Reconnection storms consume resources. Schedule rotation during low-traffic windows.
- Not testing rollback — Before rotating to new credentials, verify that you can roll back to the old credential without outage. This matters most for long-running deployments.
Mitigation:
- Use connection pooling with lazy reconnection
- Rotate credentials during low-traffic windows
- Implement connection string rotation
- Test credential rotation in staging before production
- Maintain a grace period where both old and new credentials are valid
Consistency vs Performance
Consistency and read performance pull in opposite directions. Strategies that write through to the database on every update keep the cache synchronized at all times, but the dual-write overhead slows down every mutation. Strategies that write to the cache first and sync later (write-behind) give you fast writes but create a window where the cache holds stale data. Cache-aside sidesteps this tension by treating the cache as disposable. The database is always the source of truth, and the cache is rebuilt on read misses.
The performance column in the table below refers to read performance after the cache is warm. On a cache miss, cache-aside performs identically to a direct database query. Write-through and read-through both achieve fast reads because the cache stays warm with the latest data. Refresh-ahead actually achieves the best steady-state read latency because popular entries are proactively refreshed before they expire. Users never wait on a cache miss for tracked hot data.
The complexity column captures how much infrastructure code your team needs to maintain. Cache-aside and read-through keep cache logic in the application layer, which is straightforward to debug. Write-behind and refresh-ahead introduce background workers, write queues, and access tracking that operate asynchronously. When something breaks, you cannot simply attach a debugger.
| Strategy | Consistency | Performance | Complexity |
|---|---|---|---|
| Cache-Aside | Eventual | High (after warm-up) | Low |
| Write-Through | Strong | Read-optimized | Medium |
| Write-Behind | Eventual | Write-optimized | High |
| Refresh-Ahead | Near-strong | Best for hot data | High |
Memory vs Hit Rate
The relationship between cache size and hit rate follows a curve with diminishing returns. Early gains are steep: doubling cache size when it is small improves hit rate quickly because you are capturing more of the working set. But once the cache holds most frequently accessed data, additional memory mostly sits idle. The “knee” of the curve is the point where adding more memory stops meaningfully improving hit rate, and that is your target cache size.
The working set model explains why this happens. If 80% of your requests target 20% of your data, and that 20% fits in cache, you can achieve 95%+ hit rate with a relatively small cache. If access is uniformly distributed across all keys, even a large cache delivers modest hit rates because every key competes equally for space. Understanding your access distribution before sizing your cache prevents over-provisioning.
Estimating required cache size starts with the working set formula: unique_keys_per_second * avg_value_size * avg_ttl_seconds. With 10,000 requests per second, 1KB average values, and a 5-minute TTL, you need at least 3GB for a fully utilized cache before evictions kick in. In practice, budget 1.5-2x that amount because LRU and LFU eviction policies do not perfectly track the true working set. They approximate it based on recency or frequency, which can lag behind sudden changes in access patterns.
Larger caches achieve higher hit rates, but the relationship is not linear:
- Working set fits in cache: 95%+ hit rate achievable
- Working set exceeds cache: Hit rate drops proportionally
- Diminishing returns: After the “knee” of the curve, adding memory yields minimal improvement
Latency vs Durability
Write latency and data durability sit on opposite ends of a spectrum. Write-through pays the full database latency on every write so that cache and database are always in sync. If the cache crashes, the database has the latest copy. Write-behind returns immediately after the cache is updated and flushes to the database asynchronously, which gives you low write latency but opens a window where data exists only in the cache. If the cache node fails before the flush, those writes are gone.
The durability risk is not hypothetical. Write-behind caches accumulate mutations in a write buffer or queue. A power failure, OOM kill, or network partition before the flush means the database never sees those updates. Applications that can tolerate lost writes (logging, metrics, analytics) benefit most from write-behind. Applications where every write must persist (financial transactions, inventory updates) should never use it.
Cache-aside occupies the middle ground: writes go directly to the database, so durability is guaranteed by the database itself. The cache is disposable. On a cache failure, you lose nothing that is not already safely stored. The trade-off is that reads immediately after a write see stale data until the cache is invalidated or expires.
| Approach | Latency | Durability | Risk |
|---|---|---|---|
| Write-through | Higher (waits for DB) | Best (dual-write) | Low |
| Write-behind | Lowest (async) | Risk of loss | Higher |
| Cache-aside | Variable | Database-only | Medium |
Implementation Complexity vs Operational Burden
Code complexity and operational complexity are not the same thing. A pattern can be simple to implement but hard to operate in production, or vice versa. Cache-aside requires the least application code, just a few lines of get-check-set logic, and is straightforward to debug because every step is explicit. Write-behind requires more application code (a write queue, batch flush logic, error handling for partial failures) but the real burden is operational: background workers need monitoring, the write buffer needs capacity management, and failures during the flush window produce data that is hard to reproduce.
Read-through looks simple from the application side (just call the cache), but it requires every cache node in your cluster to implement the same miss-handling logic. If your cache library does not support read-through natively, you end up building it yourself. This is why managed cache services like ElastiCache and Memorystore often provide read-through as a built-in feature. It is easier to operate when the infrastructure handles it.
Refresh-ahead has the highest combined burden. The application code tracks access frequency to identify hot keys, a background thread decides when to refresh, and the refresh logic must avoid thundering herds. Operationally, you need to monitor refresh hit rates, tune the refresh threshold, and detect when the refresher is wasting cycles on items that are no longer popular. Teams often underestimate this operational cost until they find themselves constantly tuning refresh parameters in production.
| Pattern | Code Complexity | Operational Complexity |
|---|---|---|
| Cache-Aside | Low | Low |
| Read-Through | Low | Medium |
| Write-Through | Medium | Medium |
| Write-Behind | High | High |
| Refresh-Ahead | High | High |
Quick Recap + Interview Questions
Key Bullets
- Cache-aside is the default strategy for most read-heavy workloads
- Write-through ensures strong consistency but increases write latency
- Write-behind batches writes for performance but risks data loss
- Refresh-ahead eliminates misses for popular items but adds complexity
- Always implement stampede protection when cache misses could cascade
- Monitor hit rate, memory usage, and eviction counts continuously
Copy/Paste Checklist
# Cache-Aside Implementation Checklist
- [ ] Check cache first (redis.get)
- [ ] On miss, query database
- [ ] Populate cache with TTL (redis.setex)
- [ ] On write, invalidate cache (redis.delete), don't update
- [ ] Implement stampede protection with locks
- [ ] Cache null values with short TTL to prevent penetration
- [ ] Monitor hit rate - should be >80%
- [ ] Set appropriate TTLs based on data freshness requirements
- [ ] Log cache hits and misses for observability
- [ ] Use circuit breaker for cache failures
# TTL Selection Guide
- [ ] User profiles: 15-60 minutes
- [ ] Session data: 24 hours
- [ ] API responses: 5-30 minutes
- [ ] Static config: 1-24 hours
- [ ] Product catalog: 1-24 hours
- [ ] Real-time data: No caching or very short TTL (30-60 seconds)
Best Practices Summary
Caching is a practical tool, not an architectural goal. Start simple, measure what you actually need, and add complexity only when real data tells you to. The patterns in this guide each serve a specific trade-off: pick the one that matches your workload’s read/write ratio and consistency requirements.
A few principles hold across all strategies: always set TTLs (no key should live forever), always handle cache failure gracefully (fall back to the database), and always monitor hit rates.
The sections below fold these principles into concrete architecture guidelines and operational rules. Use them as a reference when designing or debugging your caching layer.
Caching decision framework:
| Your workload | Recommended starting strategy |
|---|---|
| Read-heavy, eventual consistency OK | Cache-aside |
| Read-heavy, strong consistency needed | Write-through |
| Write-heavy, durability not critical | Write-behind |
| Known hot set, minimize miss latency | Refresh-ahead |
| Simple key-value, memory-constrained | Memcached |
The table above is a starting point, not a rule. Your actual hit rate, memory pressure, and consistency requirements will tell you when to deviate. If cache-aside gives you 95% hit rate and your database handles the remaining 5% comfortably, there is no reason to add write-through complexity.
Common mistakes to avoid:
- Setting TTLs too long because you are afraid of cache misses — stale data is often worse than a miss
- Using cache-aside for data that changes on every write — you are just adding overhead with no benefit
- Implementing write-behind without a write queue that survives restarts — you will lose data
- Adding caching before measuring — if your database handles the load, keep it simple
- Forgetting to invalidate on write — cache and database drift apart silently until someone notices
Architecture Principles
Cache as a win, not a requirement. If your database handles load fine, you may not need caching. Add caching when you have measurable latency or throughput problems. Premature caching adds operational complexity and consistency headaches before you have data that proves it helps.
Design for cache failure. Your application should degrade gracefully when the cache is unavailable. This means falling back to the database directly when cache connections fail, not returning errors. Implement circuit breakers that open after a threshold of cache failures and close after a recovery period. This prevents cache infrastructure problems from cascading into application outages.
Keep the cache stateless. Cache nodes should not hold state that cannot be recovered. If a cache node restarts, any other node should be able to serve the same keys. This is what makes horizontal scaling possible and what allows cache restarts to be handled as routine events rather than incidents. If you find yourself needing to persist cache state across restarts, consider whether that data belongs in a database instead.
Instrument everything. Cache hit rates, eviction counts, memory usage — you cannot tune what you cannot measure. At minimum, track hit rate, memory utilization, and command latency percentiles. Log cache misses at WARN level in production so you can detect when your working set stops fitting. Set up alerts for eviction spikes and memory pressure before they cause cascade failures.
Operational Guidelines
Start with cache-aside. It is the simplest strategy with the best debuggability. Every step in cache-aside is explicit in application code — you can read the flow and understand exactly what happens on hit and miss. Add complexity only when measurements tell you to. If your hit rate is below 80% after sizing the cache correctly, cache-aside is not the problem; your access patterns may simply not suit caching.
Use TTLs on everything. No key should live forever. TTLs prevent unbounded memory growth and ensure eventual consistency even when your invalidation logic misses a code path. The one exception is data that is explicitly permanent and immutable — in that case, use immutable in your Cache-Control header and version the URL instead of relying on no TTL.
Namespace your keys. Use prefixes like app:env:entity:id to prevent collisions in shared cache infrastructure. In multi-tenant environments or when multiple applications share a Redis instance, key collisions cause data corruption that is extremely difficult to debug. A consistent namespace also makes it easy to scan and delete all keys for a specific service during deployments or incidents.
Monitor the 80% threshold. Cache hit rate should be above 80-90% for well-tuned caches. If it is lower, either your working set does not fit or your access patterns are too uniform. Before adding more cache capacity, check your key distribution. A few extremely hot keys can keep hit rate low even with large cache if they are evicting the rest of the working set. Also check for cache stampedes, which can artificially depress hit rate during TTL expiration events.
Test failure modes. Periodically kill cache nodes and verify your application handles it gracefully. This is the only way to know whether your circuit breakers, fallbacks, and connection pools actually work under failure conditions. Do this in staging first, but also do it in production during low-traffic windows — real failure modes often differ from simulated ones.
Code Quality
- Never use cache as primary store. The database is always the source of truth.
- Invalidate on write, never update. Delete cache entries when data changes rather than trying to keep cache and database in sync.
- Handle the null case. Cache null values to prevent cache penetration attacks.
- Protect against stampedes. Use locks or probabilistic early expiration when cache misses are expensive.
Interview Questions
Cache-aside (lazy loading): the app checks the cache first, loads from the database on a miss, then populates the cache. Writes go directly to the database, and the cache is invalidated afterward.
Write-through: every write goes to both the cache and database together. The operation does not return until both succeed.
Cache-aside wins for read-heavy workloads where brief inconsistency is acceptable. Write-through makes more sense when consistency matters more than write speed and writes are infrequent relative to reads.
A cache stampede (thundering herd) happens when a popular entry expires and multiple concurrent requests all try to rebuild it at the same time, overwhelming the database.
Prevention strategies:
- Lock-based protection: only one request rebuilds the cache; others wait and retry.
- Probabilistic early expiration: randomly refresh entries before they expire based on a probability function.
- Mutex + early expiration combined: refresh early but coordinate with locks so only one request does the work.
- Background refreshing: a separate thread or process keeps popular entries warm before they expire.
Consistent hashing maps keys to cache nodes based on hash values. When nodes are added or removed, only K/n keys remap (where K is total keys, n is nodes), minimizing cache misses during scaling.
Key benefits:
- Less cache invalidation needed during scaling events
- Better load distribution across nodes
- Easier horizontal scaling for cache clusters
Cache penetration occurs when requests repeatedly query for keys that do not exist in the cache or database. Each request bypasses the cache and hits the database, negating the cache's purpose.
Mitigations:
- Cache null values: store a marker (like "NULL") for non-existent keys with a short TTL to prevent repeated lookups.
- Bloom filters: use a bloom filter to quickly determine if a key might exist before querying the cache.
- Input validation: sanitize cache keys to reject obviously invalid requests early.
Write-behind batches database writes in the background, returning immediately after the cache is updated. Write-through waits for both cache and database to succeed before returning.
Choose write-behind when:
- Write latency matters more than immediate durability
- You are collecting metrics, events, or analytics where losing a few writes is acceptable
- You want to reduce database load from burst writes
- Data loss risk is acceptable (your application can tolerate retransmission or recomputation)
Skip write-behind when data consistency is critical or you cannot tolerate any data loss.
TTL selection depends on three factors:
- Staleness tolerance: real-time data (prices, inventory) needs short TTLs (seconds to minutes). Static data (config, documentation) can use hours or days.
- Miss penalty: high miss penalty (expensive database queries) suggests longer TTLs to maximize hit rate.
- Access decay pattern: content that spikes in popularity then drops (social posts) needs shorter TTLs than evergreen content.
Best practice: add jitter (+/- 10%) to TTLs to prevent synchronized expiration of related keys.
TTL invalidation: entries automatically expire after a fixed duration. Simple, requires no application logic, but cannot provide immediate consistency when data changes.
Event-driven invalidation: when data changes in the database, a message is published (pub/sub) and all cache nodes delete the corresponding entry. Provides immediate consistency but requires more infrastructure and can miss events during failures.
The hybrid approach uses event invalidation for immediate consistency with TTL as a safety net for missed events. This is the most robust pattern for production systems.
A two-tier cache places a small, fast local cache (L1) in front of a larger, distributed cache (L2). L1 is typically an in-memory cache on each application server. L2 is a shared cache like Redis or Memcached.
Benefits:
- L1 hit rate of 50-60% for shared popular content (users on same machine accessing same data)
- Ultra-low latency for L1 hits (microseconds vs milliseconds for L2)
- L2 provides capacity for the warm cache beyond what fits in local memory
- Reduces cross-network traffic to L2 cache
YouTube's architecture uses exactly this pattern with per-machine L1, distributed L2, and CDN at the edge.
Cache-aside: cache is populated on read misses. Users occasionally experience cache miss latency.
Refresh-ahead: cache entries are proactively refreshed before they expire, based on predicted access patterns. Popular entries stay perpetually warm.
Trade-offs:
- Pro: eliminates cache miss latency for tracked popular items; smoother performance under varying loads
- Con: wasted resources refreshing items not actually needed; complexity in tracking truly popular keys; risk of refreshing stale data
Best for: known hot data sets where read latency matters more than wasted refresh cycles.
Primary metrics:
- Hit rate: hits / (hits + misses). Should stay above 80-90%. Drop indicates working set does not fit or access pattern changed.
- Memory usage: used_memory / maxmemory. Alert at 70%, critical at 80%.
- Eviction count: Rate of evicted_keys. High rate indicates memory pressure.
- Command latency: P50, P95, P99 for GET/SET operations.
Secondary metrics:
- Connection count (sudden drops indicate connection issues)
- Replication lag for replicated setups (should stay below 100ms)
- Error rate (connection errors, timeout errors)
Cold start problems occur when a cache restarts or when new data becomes hot without warning. Design for both scenarios:
- Pre-warming on restart: After a cache node restarts, run a background job that populates the cache with the most frequently accessed keys before serving traffic.
- Predictive warming: Track access patterns and pre-populate cache for data that is likely to become hot (scheduled events, expected traffic spikes).
- Staggered key population: Avoid repopulating everything at once by staggering cache population based on key popularity.
- Request coalescing: During cold start, allow only one request to rebuild a missing key while others wait. Prevents multiple requests from hitting the database simultaneously.
For Twitter-style workloads where content popularity spikes and then decays, warming at write time (fanout-on-write) trades write amplification for consistent read latency.
LRU (Least Recently Used): Evicts the least recently accessed item. Good for temporal access patterns where recently accessed items are likely to be accessed again.
LFU (Least Frequently Used): Evicts the least frequently accessed item. Better for sustained hot data where popularity is stable over time.
TTL-based: Entries expire after a fixed time regardless of access frequency. Best for data that naturally becomes stale.
When to choose:
- LRU: General purpose, works well when access patterns have temporal locality. Memcached defaults to LRU.
- LFU: When you have stable hot sets and want to protect frequently-accessed items from being evicted by one-time accesses. Redis uses LFU for volatile keys.
- TTL: When data freshness matters more than access frequency. Always use TTL as a safety net even with LRU/LFU.
Most production systems use LRU with TTL as a complementary eviction mechanism rather than relying on a single policy.
Cache sharding: Partition data by entity type or key prefix. All data for a user stays in the same shard, enabling multi-key operations and pipelining within a shard.
Consistent hashing: Map keys to nodes based on hash values. Provides better load distribution when nodes are added or removed because only K/n keys remap.
When to use sharding:
- You have entity types with different access patterns and sizes
- You need atomic multi-key operations within an entity
- You want simpler debugging (data for entity X is always on shard Y)
When to use consistent hashing:
- Uniform distribution of keys across nodes is critical
- You frequently add/remove cache nodes
- You want to minimize cache invalidation during scaling
Many systems combine both: consistent hashing within shards to handle node failure and rebalancing within each shard's node group.
Cache poisoning occurs when an attacker injects malicious data into your cache that is then served to many users. Usually achieved by exploiting cache key collision or polluting shared cache with malicious values.
Prevention strategies:
- Key validation: Sanitize cache keys to reject special characters, extremely long keys, or malformed input that could become injection vectors.
- Key namespacing: Use prefixes like `app:env:entity:id` to prevent collision between different applications sharing cache infrastructure.
- Input validation before caching: Validate data before storing in cache. Do not cache unchecked user input.
- Cache access controls: Implement authentication for cache access and audit who accesses what.
- Integrity checks: Sign cached values and verify signature before serving. Prevents tampering with cached data.
If your cache is shared across multiple applications, a compromised app can poison data that affects other applications. Namespacing and access controls are critical in multi-tenant cache deployments.
In microservices, the same data (e.g., user profile) may be cached by multiple services independently. This creates consistency challenges:
Pattern: Single source of truth with pub/sub invalidation
- The service owning the data publishes invalidation events when data changes.
- All other services subscribe and invalidate their local caches.
- TTL acts as a safety net if events are missed.
Pattern: Cache-aside with external invalidation
- Central cache layer (Redis) holds canonical cached data.
- Services read from central cache instead of maintaining their own caches.
- Simpler consistency model but central cache becomes a dependency.
Key consideration: You cannot have strong consistency across independent caches. Design for eventual consistency and use write-through or event invalidation to minimize the inconsistency window. If strict consistency is required, bypass caches on reads and use write-through on writes.
Latency impact: Cache hit latency is typically 0.1-1ms vs 5-50ms for database queries. Each miss adds ~10-50ms latency per request.
Throughput impact: Database queries limit concurrent operations due to connection pool constraints. Cache hits free database connections for other requests.
Capacity calculation:
- If database supports 10,000 queries/second at 50ms latency, at 99% hit rate you need only 100 queries/second from database.
- This means 1% of requests are slow (50ms) and 99% are fast (0.5ms). Average latency = 0.99 * 0.5ms + 0.01 * 50ms = 0.995ms.
- At 95% hit rate: 0.95 * 0.5ms + 0.05 * 50ms = 2.975ms average. A 5% drop in hit rate causes ~3x increase in average latency.
Rule of thumb: 99% hit rate gives ~1ms average latency. 95% gives ~3ms. 90% gives ~5.5ms. The miss penalty dominates once hit rate drops below 95%. Cache tuning efforts should target 95%+ hit rate on the hot working set.
Rate limiting using a distributed cache like Redis uses atomic increment operations with expiry:
# Sliding window rate limiter def rate_limit(key, limit, window_seconds): current = redis.incr(key) if current == 1: redis.expire(key, window_seconds) return current <= limitFixed window with sliding window log (more accurate)
def sliding_window_rate_limit(key, limit, window_seconds): now = time.time() window_start = now - window_seconds redis.zremrangebyscore(key, 0, window_start) current = redis.zcard(key) if current < limit: redis.zadd(key, now, str(uuid.uuid4())) redis.expire(key, window_seconds + 1) return current < limit
Considerations:
- Use atomic operations to prevent race conditions
- Lua scripts for Redis ensure read-check-increment is atomic
- Sliding window is more accurate but costs more operations
- Fixed window is simpler but allows burst at window boundaries
Local cache (e.g., Caffeine, LRUCache):
- Ultra-low latency (microseconds) - no network round-trip
- No serialization/deserialization overhead
- Cannot share across application instances
- Lost on application restart
- Memory limited to application process size
Distributed cache (e.g., Redis, Memcached):
- Shared across all application instances
- Persist across restarts
- Network latency (0.5-2ms per operation)
- Serialization overhead
- Single point of failure (mitigated with replication)
Best practice: Use a two-tier cache: local as L1 for the ultra-hot set, distributed as L2 for the warm cache. This gives you microsecond latency for L1 hits while sharing data across instances via L2.
Step 1: Identify the pattern
- Sudden latency spike: likely cache node failure or network partition
- Gradual performance degradation: likely memory pressure, increasing evictions
- Intermittent issues: likely connection pool exhaustion or periodic garbage collection
Step 2: Check metrics
- Hit rate drop: working set grew beyond cache size or access pattern changed
- Memory usage spike: likely key accumulation, TTL misconfiguration, or memory leak
- High eviction rate: cache undersized for working set
Step 3: Check logs
- Connection timeouts: network issues or cache overload
- OOM errors: maxmemory misconfigured or eviction policy not working
Step 4: Test assumptions
- Bypass cache and hit database directly to isolate whether cache is the problem
- Use cache introspection commands (Redis INFO, Memcached stats) to dump internal state
Step 5: Fix and verify
- Add capacity or tune eviction policy
- Implement circuit breaker to degrade gracefully
- Monitor to confirm fix worked
Scenario: Write-heavy workload with strong consistency requirements
Imagine a real-time bidding system where each ad impression generates a write, and every read must reflect the most recent bid state (no staleness allowed).
Why caching hurts:
- Cache-aside introduces eventual consistency - reads might return stale bid data, causing incorrect pricing
- Write-through doubles write latency (cache + database) for every impression
- Cache invalidation logic adds complexity and potential for bugs in hot path
- Cache might be populated with data that is never read again (each bid is unique)
Better approach:
- Use the database as the primary store with proper indexing
- Consider database read replicas if read latency is the concern
- Only add caching when measurements prove it helps
The key lesson: caching trades consistency for performance. When consistency is more important than performance (financial systems, real-time bidding), caching can actively harm your system by introducing bugs that are hard to reproduce (race conditions between cache and database) while adding complexity.
Further Reading
Books
- Redis in Action by Josiah Carlson — Comprehensive guide to Redis with production examples covering replication, sharding, and performance tuning.
- Designing Data-Intensive Applications by Martin Kleppmann — The definitive text on distributed systems, caching patterns, and data consistency models.
- Effective Redis by Alan Grories — Practical patterns and anti-patterns for Redis in production.
Articles and Papers
- Redis Architecture - DZone — Visual walkthrough of Redis internals and architecture choices.
- How Consistent Hashing is Used in Load Balancing — Deep dive into consistent hashing beyond caching.
- Cache Warming at Twitter — Twitter’s engineering blog on their cache warming strategy.
- Memcached internals and tuning — Official memcached internals documentation.
- Probabilistic Early Expiration paper — Academic paper on stampede protection algorithms.
Documentation
- Redis Documentation — Official Redis docs including client docs, administration, and best practices.
- Memcached Wiki — Community-maintained memcached documentation.
- Cache stampede prevention techniques — Redis-specific approaches to the thundering herd problem.
Conclusion
There is no single best caching strategy. Cache-aside is the default because it covers the most cases with the least complexity. But you’ll encounter situations where write-through or refresh-ahead fits better.
Start simple. Measure your hit rate. Add complexity only when the data tells you to.
Category
Related Posts
Cache Stampede Prevention: Protecting Your Cache
Learn how single-flight, request coalescing, and probabilistic early expiration prevent cache stampedes that can overwhelm your database.
Cache Patterns: Thundering Herd, Stampede Prevention, and Cache Warming
A comprehensive guide to advanced cache patterns — thundering herd, cache stampede prevention with distributed locking and probabilistic early expiration, and cache warming strategies.
Distributed Caching: Scaling Cache Across Multiple Nodes
A comprehensive guide to distributed caching — consistent hashing, cache sharding, replica consistency, cache clustering, and handling the unique challenges of multi-node cache environments.