Caching Patterns
Overview
Caching patterns define the relationship between your application, the cache, and the underlying data store. Each pattern has different consistency guarantees, performance characteristics, and failure modes. Choosing the right pattern depends on whether your workload is read-heavy or write-heavy, how stale data can be, and how complex you want your application logic.
Cache-Aside (Lazy Loading)
The application manages the cache explicitly. It checks the cache first, and on a miss, fetches from the database and populates the cache.
How It Works
Read path:
1. Application checks cache for key "user:123"
2. Cache hit -> Return cached data
3. Cache miss -> Query database for user 123
4. Store result in cache with TTL
5. Return data to caller
Write path:
1. Application writes to database
2. Application invalidates (deletes) the cache entry
3. Next read will miss and re-populate the cache
Pseudocode:
function getUser(userId):
cached = cache.get("user:" + userId)
if cached != null:
return cached
user = database.query("SELECT * FROM users WHERE id = ?", userId)
cache.set("user:" + userId, user, ttl=300)
return user
function updateUser(userId, data):
database.update("UPDATE users SET ... WHERE id = ?", userId, data)
cache.delete("user:" + userId)
When to Use
- Read-heavy workloads where most data is read far more than written
- When you can tolerate brief staleness after writes
- When you want full control over what gets cached and when
- General-purpose pattern that works for most use cases
Trade-Offs
Pros:
- Only frequently accessed data ends up in cache (no wasted memory)
- Cache failure is not catastrophic (reads fall through to database)
- Simple to implement and reason about
- Works with any cache technology
Cons:
- First request for each item is always a cache miss
- Potential for stale reads between write and cache invalidation
- Application code must manage cache logic
- Cache stampede risk when popular items expire
Race condition:
Thread A reads from DB (gets old value)
Thread B updates DB and invalidates cache
Thread A writes old value to cache
Result: Cache has stale data until TTL expires
Mitigation: Use cache-aside with short TTLs or versioned keys.
Real-World Usage
Facebook uses cache-aside with Memcached in front of MySQL. The application explicitly manages cache reads and invalidations. Their TAO system extends this pattern with a graph-aware cache.
Read-Through
The cache itself is responsible for loading data from the database on a miss. The application only talks to the cache.
How It Works
Read path:
1. Application requests "user:123" from cache
2. Cache hit -> Return cached data
3. Cache miss -> Cache queries database for user 123
4. Cache stores the result and returns it to application
The application never directly queries the database for reads.
The cache library or proxy handles the database interaction.
Pseudocode:
// Cache is configured with a loader function
cache.configure(loader: function(key):
return database.query("SELECT * FROM users WHERE id = ?", key.split(":")[1])
)
// Application code is simple
function getUser(userId):
return cache.get("user:" + userId) // Cache handles miss internally
When to Use
- When you want to simplify application code by moving cache logic into infrastructure
- When the same data loading pattern applies consistently across many access points
- When using a cache library or proxy that supports read-through natively
Trade-Offs
Pros:
- Application code is simpler (no cache miss handling)
- Cache logic is centralized, not scattered across the codebase
- Consistent cache population behavior
Cons:
- Cache must understand how to query the data store
- Less flexibility for complex data loading (joins, aggregations)
- First request still misses (same as cache-aside)
- Tighter coupling between cache and database
Used by: Hibernate second-level cache, DynamoDB Accelerator (DAX)
Write-Through
Every write goes through the cache to the database. The cache is always up to date.
How It Works
Write path:
1. Application writes data to cache
2. Cache synchronously writes data to database
3. Write is acknowledged only after both cache and database are updated
Read path:
1. Application reads from cache
2. Cache always has the latest data (was updated on write)
3. Cache miss only happens for data never written through this cache
Pseudocode:
function updateUser(userId, data):
// Cache handles both cache update and database write
cache.put("user:" + userId, data)
// Internally: cache updates itself AND writes to database
// Returns only after both succeed
function getUser(userId):
return cache.get("user:" + userId)
When to Use
- When you cannot tolerate any staleness between cache and database
- When writes are not latency-sensitive (write path is slower due to synchronous database write)
- Combined with read-through for a fully cache-managed data path
Trade-Offs
Pros:
- Cache is never stale (write updates cache before acknowledging)
- Read path is always fast (data is already in cache)
- No cache invalidation logic needed
- Data consistency between cache and database
Cons:
- Write latency increases (must write to both cache and database)
- Wasted cache space (data written but rarely read is still cached)
- Extra infrastructure complexity (cache must manage database writes)
- If cache fails, writes fail (cache is in the critical write path)
Write-through alone is insufficient:
Data loaded directly into the database (batch jobs, migrations)
bypasses the cache, causing staleness.
Combine with read-through to handle this case.
Write-Behind (Write-Back)
The application writes to the cache, which asynchronously writes to the database later. This decouples write latency from database performance.
How It Works
Write path:
1. Application writes data to cache
2. Cache acknowledges the write immediately
3. Cache queues the write for asynchronous database update
4. Background process flushes queued writes to database
Timeline:
T0: Application writes to cache (acknowledged in 1ms)
T1: Cache queues the write
T2: Background flush writes to database (50ms, but non-blocking)
Pseudocode:
function updateUser(userId, data):
cache.put("user:" + userId, data)
// Returns immediately. Database write happens later.
// Background process (managed by cache):
function flushToDatabase():
while true:
batch = writeQueue.drain(maxSize=100, maxWait=5seconds)
database.batchUpdate(batch)
When to Use
- Write-heavy workloads where database write latency is a bottleneck
- When you can tolerate a window where database is behind the cache
- Batch write optimization (multiple writes to the same key collapse into one database write)
- Absorbing write spikes without overloading the database
Trade-Offs
Pros:
- Very fast write acknowledgment (only cache write)
- Database write batching improves throughput
- Absorbs write spikes gracefully
- Multiple rapid updates to the same key result in one database write
Cons:
- Risk of data loss if cache crashes before flushing to database
- Database is temporarily behind the cache (inconsistent)
- Complex failure handling (what happens if database write fails?)
- Harder to debug (timing-dependent behavior)
- Not suitable for data that must be immediately durable
Real-world usage:
CPU write-back caches use this pattern at the hardware level.
Some ORM frameworks batch database writes similarly.
Refresh-Ahead
The cache proactively refreshes items before they expire, so reads never hit a cold cache for popular items.
How It Works
Configuration:
TTL = 300 seconds
Refresh-ahead factor = 0.8 (refresh at 80% of TTL)
Timeline:
T0: Item cached with 300s TTL
T240: Item is at 80% of TTL (240/300)
Cache triggers background refresh from database
T245: Background refresh completes, TTL resets to 300s
T300: Original TTL would have expired, but item was already refreshed
Without refresh-ahead:
T300: TTL expires
T301: Next read is a cache miss (slow)
T302: Database queried, cache repopulated
With refresh-ahead:
T240: Background refresh starts
T245: Cache updated with fresh data
T300: Item is still fresh (TTL was reset at T245)
No cache miss for active items.
When to Use
- Items that are accessed frequently and consistently
- When cache miss latency is unacceptable for hot items
- Predictable access patterns where you know which items stay hot
- Combined with cache-aside for items that may go cold
Trade-Offs
Pros:
- Eliminates cache misses for popular items
- Smooth, predictable latency (no periodic spikes from TTL expiry)
- Background refresh does not block reads
Cons:
- Wastes resources refreshing items that might not be read again
- Adds complexity (background refresh logic, scheduling)
- Needs accurate prediction of which items stay hot
- If refresh fails, falls back to cache miss behavior
Choosing the Right Pattern
Decision framework:
Read-heavy, simple application:
-> Cache-aside
Most common, simplest to implement.
Read-heavy, want simpler application code:
-> Read-through
Cache handles miss logic internally.
Strong consistency required:
-> Write-through + Read-through
Cache always matches database.
Write-heavy, latency-sensitive:
-> Write-behind
Fast writes, async database update.
Latency-critical for popular items:
-> Refresh-ahead + Cache-aside
Proactive refresh for hot items, lazy load for cold items.
Common production combinations:
Cache-aside + TTL:
Simple and effective for most workloads.
Read-through + Write-through:
Full cache management, strong consistency.
Cache-aside + Write-behind:
Fast reads and writes, eventual consistency.
Cache-aside + Refresh-ahead:
Zero-miss reads for hot items, lazy for cold items.
Real-World Pattern Usage
Amazon DynamoDB Accelerator (DAX) implements read-through and write-through. Applications point to DAX instead of DynamoDB. DAX handles caching transparently, returning cached results for reads and writing through to DynamoDB on writes.
Hibernate ORM uses read-through and write-through for its second-level cache. Entity reads check the cache first, and writes update both cache and database.
Facebook's Memcache deployment uses cache-aside extensively. The application manages all cache interactions. They chose this pattern because it gives maximum control and their workload is overwhelmingly read-heavy.
Redis supports write-behind patterns through Redis Gears or application-level implementation. Some teams use Redis Streams as a write-behind queue.
Common Pitfalls
- Using write-behind for critical data: If the cache crashes before flushing to the database, queued writes are lost. Never use write-behind for financial transactions or data that must be immediately durable.
- Write-through without considering write volume: Every write pays the cost of both cache and database operations. For high-volume writes, this doubles your write overhead.
- Refresh-ahead for long-tail access patterns: If most items are accessed only once or twice, refresh-ahead wastes resources refreshing items nobody will read again.
- Not handling cache failure in write-through: If the cache is unavailable, should writes fail or bypass the cache? Decide this upfront.
- Mixing patterns without a clear strategy: Using different patterns for different data types is fine, but document which pattern applies where. Inconsistency leads to bugs.
- Invalidate vs update confusion: Cache-aside typically invalidates (deletes) on write. Updating the cache on write is a different pattern with different race condition characteristics.
Key Takeaways
- Cache-aside is the right default pattern for most applications. It is simple, well-understood, and handles cache failures gracefully.
- Write-through provides strong consistency between cache and database at the cost of higher write latency.
- Write-behind dramatically improves write performance but risks data loss and adds operational complexity.
- Read-through simplifies application code by moving cache miss logic into the cache layer itself.
- Refresh-ahead eliminates cache misses for popular items but wastes resources on items with unpredictable access patterns.
- In practice, systems combine multiple patterns: cache-aside for general data, write-through for consistency-critical data, and refresh-ahead for latency-sensitive hot items.