Cache-Aside Pattern
Load data into cache on demand: the cache-aside flow, cache miss handling, consistency considerations, and stampede prevention.
What Is Cache-Aside?
Cache-Aside (also called Lazy Loading) is the most common caching pattern used in production systems. The application code is responsible for managing the cache — data is only loaded into the cache when it is actually requested, and the application must handle both cache hits and misses explicitly. Neither the cache nor the data store knows about each other; the application sits in the middle and mediates all reads and writes.
This pattern is the default choice at companies like Netflix, Twitter, and Airbnb for their Redis-based caching layers because it gives engineers full control over what goes into the cache, when it expires, and how it is invalidated.
The Cache-Aside Read Flow
The read path follows a consistent three-step check. On every read, the application first looks in the cache. If the value is present (cache hit), it is returned immediately. If the value is absent (cache miss), the application fetches the data from the primary data store, writes the result into the cache with an appropriate TTL, then returns the value to the caller.
The Cache-Aside Write Flow
On writes, the application updates the primary data store directly, then invalidates (deletes) the corresponding cache entry rather than updating it. The updated value will be re-populated on the next read. This avoids the race condition of writing a stale value to the cache while a concurrent request might be fetching fresh data.
Invalidate, Don't Update
On writes, always delete the cache key rather than writing the new value. Updating the cache on write introduces race conditions: two concurrent writers could update the cache in the wrong order, leaving stale data permanently.
def get_user(user_id: str) -> User:
cache_key = f"user:{user_id}"
# Step 1: Try cache first
cached = redis.get(cache_key)
if cached:
return deserialize(cached)
# Step 2: Cache miss — fetch from DB
user = db.query("SELECT * FROM users WHERE id = ?", user_id)
if not user:
return None
# Step 3: Populate cache with TTL
redis.setex(cache_key, ttl=3600, value=serialize(user))
return user
def update_user(user_id: str, data: dict) -> None:
# Step 1: Write to DB first
db.execute("UPDATE users SET ... WHERE id = ?", user_id, data)
# Step 2: Invalidate cache (delete, not update)
redis.delete(f"user:{user_id}")Consistency Considerations
Cache-Aside provides eventual consistency. Between the moment a write invalidates the cache and a reader re-populates it, all readers go to the database. This is generally acceptable but has two edge cases worth knowing:
- Stale reads after write: If invalidation fails (network blip to Redis), the cache holds stale data until TTL expiry. Always set a reasonable TTL as a safety net.
- Cold start penalty: A freshly deployed service or a cache flush means every request hits the database. Use cache warming strategies for predictable cold-start scenarios.
- Cache stampede (thundering herd): When a popular key expires, many concurrent requests all miss and simultaneously query the database, causing a spike.
Cache Stampede Prevention
When a high-traffic cache key expires, hundreds of simultaneous requests can hammer the database at once — the cache stampede or thundering herd problem. Three standard mitigations exist:
| Technique | How It Works | Trade-off |
|---|---|---|
| Mutex / Lock | First miss acquires a distributed lock and re-populates; others wait | Added latency for waiters; lock must be released on failure |
| Probabilistic Early Expiry | Re-compute value slightly before TTL expires with some probability | Slight over-computation but no lock contention |
| Background Refresh | Serve stale data immediately; async worker refreshes in background | Requires a separate TTL for stale-ok window |
Interview Tip
Interviewers love asking about cache stampede. Lead with the mutex lock approach, then mention probabilistic early expiry (also called 'XFetch') as a lock-free alternative. Demonstrating knowledge of both signals depth. Also mention that Redis 6.2+ has a built-in `GETDEL` + `SET NX` pattern for distributed locking.
When to Choose Cache-Aside
| Situation | Cache-Aside Good? | Why |
|---|---|---|
| Read-heavy, write-infrequent | Yes | Cache hit rate is high; few invalidations |
| Data that can be slightly stale | Yes | TTL-based consistency is sufficient |
| Unpredictable access patterns | Yes | Only popular data ends up in cache |
| Write-heavy workloads | No | Constant invalidation means low hit rate |
| Strong consistency required | No | Stale reads possible between write and re-populate |
Real-World Example: Twitter's Timeline Cache
Twitter uses Cache-Aside to store pre-computed timelines in Redis. When a user loads their feed, the app checks Redis first. On a miss, the fanout service assembles the timeline from Cassandra and writes it back. Because timelines can tolerate a few seconds of staleness, the TTL-based approach works perfectly without needing strong consistency.