Cache-Aside Pattern
Load data into the cache on demand from the backing store. A caching pattern that gives the application full control over what and when to cache.
Overview
Repeated reads against the database cost latency and connection capacity on every request. The Cache-Aside Pattern puts the application in charge of fixing that: check the cache first, and on a miss fetch from the database, store the result, and return it. The application owns the cache logic, invalidation, and consistency — it isn’t delegated to the cache provider.
It fits systems where reads dominate and a few seconds of stale data are acceptable. On write-heavy workloads or where every read must be strongly consistent, write-through caching is a better fit.
When to Use
Use the Cache-Aside Pattern when:
- Read-heavy workloads where the same data is requested frequently
- The application should control what gets cached and for how long
- Cache invalidation can be handled explicitly by the application layer
- You want a simple, portable caching strategy that works with any provider (Redis, Memcached, in-memory)
- Examples: user profiles, product catalogs, configuration data, reference data
Skip it when writes dominate — the cache churns and rarely hits — or when a small app doesn’t need a cache layer at all.
Solution
Python
import time
from typing import Any, Callable
class CacheAside:
def __init__(self, cache: dict, ttl_seconds: float = 60):
self.cache = cache
self.ttl = ttl_seconds
self.timestamps = {}
def get(self, key: str, loader: Callable[[], Any]) -> Any:
now = time.time()
if key in self.cache:
if now - self.timestamps.get(key, 0) < self.ttl:
print(f"Cache hit: {key}")
return self.cache[key]
else:
del self.cache[key]
print(f"Cache miss: {key}")
value = loader()
self.cache[key] = value
self.timestamps[key] = now
return value
def invalidate(self, key: str):
self.cache.pop(key, None)
self.timestamps.pop(key, None)
# Usage
cache = {}
store = CacheAside(cache)
def load_user(user_id: int) -> dict:
# Simulate DB call
return {"id": user_id, "name": f"User {user_id}"}
user = store.get("user:1", lambda: load_user(1))
user = store.get("user:1", lambda: load_user(1)) # Cache hit
store.invalidate("user:1")
JavaScript
class CacheAside {
constructor(cache, ttlMs = 60000) {
this.cache = cache;
this.ttl = ttlMs;
this.timestamps = new Map();
}
get(key, loader) {
const now = Date.now();
if (this.cache.has(key)) {
if (now - (this.timestamps.get(key) || 0) < this.ttl) {
console.log(`Cache hit: ${key}`);
return this.cache.get(key);
}
this.cache.delete(key);
}
console.log(`Cache miss: ${key}`);
const value = loader();
this.cache.set(key, value);
this.timestamps.set(key, now);
return value;
}
invalidate(key) {
this.cache.delete(key);
this.timestamps.delete(key);
}
}
// Usage
const cache = new Map();
const store = new CacheAside(cache);
function loadUser(userId) {
return { id: userId, name: `User ${userId}` };
}
let user = store.get("user:1", () => loadUser(1));
user = store.get("user:1", () => loadUser(1)); // Cache hit
store.invalidate("user:1");
Java
import java.util.*;
import java.util.function.Supplier;
public class CacheAside<K, V> {
private final Map<K, V> cache;
private final Map<K, Long> timestamps;
private final long ttlMs;
public CacheAside(Map<K, V> cache, long ttlMs) {
this.cache = cache;
this.timestamps = new HashMap<>();
this.ttlMs = ttlMs;
}
public V get(K key, Supplier<V> loader) {
long now = System.currentTimeMillis();
if (cache.containsKey(key)) {
if (now - timestamps.getOrDefault(key, 0L) < ttlMs) {
System.out.println("Cache hit: " + key);
return cache.get(key);
}
cache.remove(key);
}
System.out.println("Cache miss: " + key);
V value = loader.get();
cache.put(key, value);
timestamps.put(key, now);
return value;
}
public void invalidate(K key) {
cache.remove(key);
timestamps.remove(key);
}
}
// Usage
CacheAside<String, Map<String, Object>> store =
new CacheAside<>(new HashMap<>(), 60000);
Map<String, Object> user = store.get("user:1", () ->
Map.of("id", 1, "name", "User 1")
);
Explanation
The Cache-Aside Pattern follows this flow:
- Read: the application checks the cache — on a hit, return; on a miss, continue.
- Load: fetch from the backing store (database, API).
- Store: write the result into the cache with a TTL.
- Invalidate: on writes and updates, delete the cache entry so the next read refreshes it.
The application is the single point of control — it decides when to read from the cache, when to fall back to the store, and when to invalidate. The trade-off is that your code owns expiry, invalidation, and failure handling. Two writes close together can also leave a stale entry until the next read or the TTL kicks in, which is why invalidation on write matters more than the TTL itself.
Variants
| Variant | Description | Use Case |
|---|---|---|
| Lazy Loading | Cache miss triggers the load | Most common; prevents unnecessary cache fills |
| Write-Through | Writes update cache and DB simultaneously | Strong consistency required |
| Refresh-Ahead | Proactively refresh before the TTL expires | Predictable access patterns |
| Multi-Level | L1 (in-memory) + L2 (Redis) + L3 (DB) | High-scale applications |
What Works
- Always set a TTL — stale data is worse than a cache miss
- Invalidate on writes — delete the cache key after DB updates to maintain consistency
- Use a circuit breaker around cache failures — if Redis is down, fall back directly to the DB
- Serialize complex objects before storing (JSON, protobuf), and version your keys so a schema change misses instead of crashing
- Monitor cache hit ratio — aim for >90% on read-heavy workloads
- Pre-warm the cache on startup for critical reference data
Common Mistakes
- Forgetting to invalidate the cache after DB writes, causing stale data
- Setting the TTL too long and serving outdated information
- Not handling cache provider failures gracefully (e.g., Redis connection lost)
- Storing too much data in cache, causing memory pressure or eviction of hot keys
- Cache stampede: many requests hit a cold cache simultaneously, overloading the DB
Troubleshooting
- Stale data right after a write: the update reached the database but the old cache entry is still alive. Delete the key in the same code path that writes, and treat TTL as the safety net, not the strategy.
- Traffic spike after a deploy or cache flush: a cold cache turns N concurrent misses into N identical database queries. Use a per-key lock or single-flight so only one request loads while the rest wait.
- Redis unreachable: every lookup becomes a slow path to the database. Wrap cache calls in a short timeout, treat errors as misses, and let a circuit breaker stop hammering a dead Redis.
- Hit ratio stuck low: keys expire too fast, the working set doesn’t fit in memory, or the wrong data is being cached. Check eviction stats before adding more capacity.
- Serialization mismatch after deploy: the stored shape changed and deserialization crashes. Version your cache keys (
user:v2:1) so old entries miss instead of breaking.
Key Takeaways
- Cache-aside keeps read logic simple: check the cache, on miss load from the store and fill the cache.
- Pair it with a TTL and invalidation on writes — TTL alone leaves a staleness window, invalidation alone breaks the first time an event is missed.
- Its weak spot is the cold-cache stampede; plan a lock or single-flight before you need it.
- Prefer it over write-through when reads dominate and the cache should stay optional — if the cache dies, the app keeps working, just slower.
Advanced Topics
Scenario: Cache-Aside for a Product API
// Cache-Aside: read from cache, on miss read from DB and fill the cache
class ProductCache {
constructor(private redis: RedisClient, private db: Pool, private ttlSec: number = 300) {}
async getProduct(id: string): Promise<Product | null> {
// 1. Try cache
const cached = await this.redis.get(`product:${id}`);
if (cached) return JSON.parse(cached);
// 2. Cache miss: read from DB
const res = await this.db.query("SELECT * FROM products WHERE id = $1", [id]);
if (res.rows.length === 0) return null;
const product = res.rows[0];
// 3. Fill cache
await this.redis.setex(`product:${id}`, this.ttlSec, JSON.stringify(product));
return product;
}
async updateProduct(id: string, data: Partial<Product>): Promise<void> {
// 1. Update DB
await this.db.query("UPDATE products SET name=$1, price=$2 WHERE id=$3", [data.name, data.price, id]);
// 2. Invalidate cache
await this.redis.del(`product:${id}`);
}
async deleteProduct(id: string): Promise<void> {
await this.db.query("DELETE FROM products WHERE id = $1", [id]);
await this.redis.del(`product:${id}`);
}
}
Invalidation strategies compared:
| Strategy | Description | Pros | Cons |
|---|---|---|---|
| TTL | Expires after N seconds | Simple | Stale data until expiry |
| Write-through | Write cache on DB write | Always consistent | Write latency |
| Write-around | Write DB only, cache on read | Fast writes | First read slow |
| Write-back | Write cache, async to DB | Fast writes | Risk of data loss |
| Explicit | Invalidate on update/delete | Full control | Requires manual code |
The flow in one pass: reads go cache → miss → database → fill cache; writes update the database and delete the cache key instead of updating it. TTL stays as the safety net for the invalidation events you miss. For a complete Redis implementation with stampede protection, see the Redis cache-aside recipe.
Common Production Pitfalls
- Caching without a TTL and relying only on invalidation — one missed event serves stale data indefinitely.
- Updating the cache on writes instead of deleting the key — that leaves two write paths to keep in sync.
- Treating the cache as required infrastructure: if the app throws when Redis is down, cache-aside becomes a liability instead of an optimization.
- Serializing objects under unversioned keys, then deploying a schema change that crashes deserialization.
- Caching per-user or per-tenant data under shared keys — a classic source of data leaks.
- Ignoring the stampede case until the first traffic spike after a deploy takes the database down.
- Filling the cache with values nobody re-reads — a low hit ratio means the cache adds latency instead of removing it.
Frequently Asked Questions
What is the difference between Cache-Aside and Read-Through?
In Cache-Aside, the application controls the cache logic: check, load, fill, invalidate. In Read-Through, the cache provider fetches from the database transparently through a loader. Cache-Aside is more explicit and portable; Read-Through hides the plumbing behind the cache layer.
How do I prevent cache stampedes?
Use a per-key lock so only one request hits the database while the rest wait — a distributed lock in Redis works, or reuse the in-flight promise in JavaScript (single-flight). Probabilistic early expiration also helps: refresh hot keys slightly before the TTL ends so misses never land all at once.
Should I cache writes (Write-Through) or invalidate them (Cache-Aside)?
Invalidating on write is simpler and safer — the cache self-heals on the next read. Write-Through updates cache and database together, which keeps reads consistent but couples every write to the cache. Use it only when stale reads are unacceptable.
When should I avoid Cache-Aside?
On write-heavy workloads where entries churn before they're re-read, when strong consistency is required on every read, or in small applications where the extra layer adds plumbing without measurable benefit.
What TTL should I use?
Start from how stale the data can be: seconds for prices or inventory, minutes for user profiles, hours for reference data. There's no universal value — a short TTL combined with invalidation on writes beats a long TTL alone.
What happens if the cache goes down?
Design for it: treat cache errors as misses with a short timeout and let the database absorb the load. That's the main advantage over Read-Through — the application degrades instead of failing. Just make sure the database can survive full read traffic without the cache absorbing it.
Related Resources
Read-Through Cache Pattern
A transparent cache layer that intercepts read requests, fetches from the data source on miss, and populates the cache automatically.
PatternWrite-Through Cache Pattern
Synchronously write to both cache and backing store so the cache always has the latest data without TTL-based invalidation.
PatternCache Invalidation Pattern
Strategies for keeping cached data fresh: TTL expiration, explicit invalidation, write-through, and event-driven cache eviction.
PatternTwo-Level Cache Pattern
Combine an L1 in-memory cache with an L2 distributed cache to reduce latency for hot keys while maintaining cache consistency across instances.
RecipeImplement the Cache-Aside Pattern with Redis
Use the cache-aside pattern to read and write data through Redis, handling cache misses, stale reads, and write-through invalidation
GuideComplete Guide to Application-Level Caching
Implement in-memory, distributed, and hybrid caches at the application layer. Covers LRU caches, TTL caches, multi-tier strategies, cache sizing, thread safety, and production patterns for Python, Java, and Node.js.