Skip to main content

Caching Patterns and Strategies

Optimize distributed systems with proven caching patterns: cache-aside, write-through, write-behind, and read-through to balance performance, consistency, and complexity.

TL;DR

Four primary caching patterns address different latency/consistency requirements: Cache-Aside (lazy loading, eventual consistency), Write-Through (strong consistency, higher latency), Write-Behind (low latency writes, replication lag), and Read-Through (transparent cache population). Combine with TTL-based expiration and LRU/LFU eviction policies. Choose based on consistency requirements (strong vs eventual), write patterns (frequent vs rare), and acceptable stale-data windows.

Learning Objectives

By the end of this article, you'll understand:

  • Four core caching patterns and their trade-offs
  • Cache invalidation strategies: TTL, event-driven, and invalidation protocols
  • Eviction policies: LRU, LFU, and application-specific strategies
  • Consistency models: strong, eventual, and relaxed guarantees
  • When to cache and when to bypass for correctness

Motivating Scenario

Your e-commerce platform's product catalog service experiences 10x traffic spike during flash sales. Database load spikes to 95%, query latency balloons from 50ms to 2+ seconds. Users see timeouts, checkout fails, revenue drops. You have a cache layer (Redis) sitting idle because you're unsure which caching pattern prevents stale data while maintaining throughput. Should writes update cache immediately or asynchronously? How do you invalidate when inventory changes? How do you prevent cache stampedes?

Core Concepts

Cache-Aside Pattern (Lazy Loading)

Application directly manages cache interaction. On read miss, load from source and populate cache.

Characteristics:

  • Application responsible for cache logic
  • Simple to implement and understand
  • Works with any cache backend (Redis, Memcached, etc.)
  • Eventual consistency model
  • Cache misses cause first request to be slow

Flow:

  1. Application requests data
  2. Check cache (hit → return)
  3. Cache miss → load from database
  4. Update cache with TTL
  5. Return to application

Best for: Web content, non-critical data, read-heavy workloads, content delivery networks.

Write-Through Pattern

Application writes to cache first; cache synchronously writes to source. Application waits for both.

Characteristics:

  • Strong consistency guaranteed
  • Higher latency (write to both cache and DB)
  • Prevents cache-DB divergence
  • Cache always contains valid data
  • Useful for critical data

Flow:

  1. Application writes to cache
  2. Cache propagates write to database
  3. Both acknowledge completion
  4. Application continues

Best for: Financial transactions, inventory systems, user account data, audit logs.

Write-Behind (Write-Back) Pattern

Application writes to cache immediately; cache asynchronously persists to source.

Characteristics:

  • Low write latency (application doesn't wait for DB)
  • Eventual consistency with durability risk
  • Cache acts as write-buffer
  • Potential data loss if cache fails before flush
  • Requires replication for durability

Flow:

  1. Application writes to cache
  2. Cache acknowledges immediately
  3. Cache queues write for background flush
  4. Eventually persists to database

Best for: Analytics, logging, non-critical updates, high-throughput write scenarios.

Read-Through Pattern

Cache layer transparently loads missing data from source, decoupling application from DB.

Characteristics:

  • Simplifies application logic
  • Cache responsible for population
  • Automatic cache warming on misses
  • Application sees cache-only interface
  • Requires cache-aware data loader

Flow:

  1. Application requests from cache
  2. Cache checks local store
  3. Cache miss → cache loader fetches from DB
  4. Cache populates and returns
  5. Application receives data

Best for: Abstracted data layers, microservices, multi-tier caching, transparent performance optimization.

Cache Invalidation Strategies

Time-Based (TTL):

  • Simplest approach
  • Fixed expiration (e.g., 5 minutes)
  • No active invalidation needed
  • Trades freshness for simplicity
  • Risk: stale data between expiry and access

Event-Driven Invalidation:

  • Database triggers cache purge on updates
  • Message queue notifies cache of changes
  • Zero staleness window
  • Requires coordinated systems
  • More operational complexity

Hybrid (TTL + Invalidation):

  • TTL as safety net
  • Events trigger immediate invalidation
  • Best of both: timely updates + eventual consistency guarantee
  • Recommended for most systems

Eviction Policies

LRU (Least Recently Used):

  • Evict least-accessed items first
  • Suits temporal locality (recent accesses predict future)
  • Low overhead tracking
  • Common default

LFU (Least Frequently Used):

  • Evict least-frequently accessed items
  • Better for skewed access patterns
  • Heavier tracking overhead
  • Suits stable access patterns

FIFO (First In, First Out):

  • Evict oldest items regardless of access
  • No tracking overhead
  • Ignores access patterns
  • Rarely optimal

Practical Example

import redis
import json
from datetime import datetime, timedelta
from typing import Optional, Any
import hashlib

class CacheStrategy:
def __init__(self, redis_host='localhost', redis_port=6379):
self.redis = redis.Redis(
host=redis_host,
port=redis_port,
decode_responses=True
)
self.cache_ttl = 300 # 5 minutes

# Cache-Aside Pattern
def cache_aside_read(self, key: str, loader_func, ttl: int = None):
"""Lazy-load with cache-aside pattern"""
ttl = ttl or self.cache_ttl

# Check cache
cached = self.redis.get(key)
if cached:
print(f"Cache HIT: {key}")
return json.loads(cached)

# Cache miss - load from source
print(f"Cache MISS: {key} - loading from source")
data = loader_func()

# Update cache
self.redis.setex(key, ttl, json.dumps(data))
return data

# Write-Through Pattern
def write_through(self, key: str, data: Any, persist_func):
"""Write to both cache and database synchronously"""
print(f"Write-Through: {key}")

try:
# Write to database first (safest)
persist_func(data)

# Then update cache
self.redis.setex(
key,
self.cache_ttl,
json.dumps(data)
)
print(f"✓ Write-Through complete: {key}")
return True
except Exception as e:
print(f"✗ Write-Through failed: {e}")
return False

# Write-Behind Pattern
def write_behind(self, key: str, data: Any, queue_func):
"""Write to cache immediately, queue DB persistence"""
print(f"Write-Behind: {key}")

# Write to cache first
self.redis.setex(
key,
self.cache_ttl,
json.dumps(data)
)

# Queue for asynchronous persistence
queue_func(key, data)
print(f"✓ Cache updated, DB write queued: {key}")

# Read-Through Pattern
def read_through(self, key: str, loader_func):
"""Transparent cache population"""
cached = self.redis.get(key)
if cached:
print(f"Cache HIT: {key}")
return json.loads(cached)

# Cache miss - transparent load
print(f"Cache MISS: {key} - loader populating")
data = loader_func()
self.redis.setex(key, self.cache_ttl, json.dumps(data))
return data

# Cache invalidation
def invalidate(self, key: str):
"""Event-driven invalidation"""
deleted = self.redis.delete(key)
print(f"Invalidated: {key} (deleted: {deleted})")

def invalidate_pattern(self, pattern: str):
"""Invalidate keys matching pattern"""
keys = self.redis.keys(pattern)
if keys:
self.redis.delete(*keys)
print(f"Invalidated {len(keys)} keys matching: {pattern}")

# Cache stampede prevention
def cache_aside_with_lock(self, key: str, loader_func, ttl: int = None):
"""Prevent cache stampede with lock"""
ttl = ttl or self.cache_ttl
lock_key = f"{key}:lock"
lock_ttl = 10 # Seconds

cached = self.redis.get(key)
if cached:
return json.loads(cached)

# Try to acquire lock
lock_acquired = self.redis.set(
lock_key,
'1',
nx=True,
ex=lock_ttl
)

if lock_acquired:
# We got the lock - load and populate
print(f"Acquired lock for: {key}")
data = loader_func()
self.redis.setex(key, ttl, json.dumps(data))
self.redis.delete(lock_key)
return data
else:
# Another request is loading - wait for cache
for _ in range(lock_ttl):
cached = self.redis.get(key)
if cached:
print(f"Got data from loading process: {key}")
return json.loads(cached)
time.sleep(0.1)

# Fallback: return from source if still no cache
return loader_func()

# Cache statistics
def get_stats(self) -> dict:
"""Get cache statistics"""
return {
'memory_used_mb': int(
self.redis.info()['used_memory']
) / 1024 / 1024,
'keys_count': self.redis.dbsize(),
'evicted_keys': int(
self.redis.info()['evicted_keys']
)
}

# Example usage
def main():
cache = CacheStrategy()

# Simulated database loader
def load_user(user_id=1):
print(" [DB] Loading user from database...")
return {'id': user_id, 'name': 'Alice', 'email': 'alice@example.com'}

def persist_user(data):
print(" [DB] Persisting user to database...")

# Cache-Aside
print("=== Cache-Aside Pattern ===")
user = cache.cache_aside_read(f"user:1", load_user)
print(f"User: {user}\n")

# Write-Through
print("=== Write-Through Pattern ===")
new_user = {'id': 2, 'name': 'Bob', 'email': 'bob@example.com'}
cache.write_through("user:2", new_user, persist_user)
print()

# Write-Behind
print("=== Write-Behind Pattern ===")
updated_user = {'id': 1, 'name': 'Alice Updated'}
cache.write_behind("user:1", updated_user,
lambda k, v: print(f" [Queue] {k} queued"))
print()

# Cache stats
print(f"Cache stats: {cache.get_stats()}")

When to Use / When Not to Use

Cache-Aside When:
  1. Reads outnumber writes by 10:1 or more
  2. Acceptable eventual consistency (stale data okay)
  3. Cache failures don
  4. ,
  5. ,
Write-Through When:
  1. Strong consistency critical (financial data)
  2. Can tolerate higher write latency
  3. Cache failures must not lose data
  4. Inventory/account systems
  5. Audit and compliance requirements

Patterns & Pitfalls

Design Review Checklist

  • Caching pattern selected matches consistency requirements
  • TTL tuned to access patterns (profile before guessing)
  • Eviction policy configured (LRU/LFU with max memory bounds)
  • Cache invalidation strategy documented (TTL, event-driven, hybrid)
  • Cache stampede prevention implemented if needed
  • Write-Behind durability guaranteed (durable queue before ACK)
  • Write-Through latency impact measured and acceptable
  • Cache failures handled gracefully (fallback to DB)
  • Monitoring tracks hit rate, evictions, replication lag
  • Documentation explains pattern choice and consistency guarantees

Self-Check

Ask yourself:

  • What consistency model does my data require (strong vs eventual)?
  • What's my cache hit rate and is it acceptable?
  • Can my application tolerate seeing stale data?
  • What happens if the cache fails completely?
  • Are cache writes persisted durably before I acknowledge?

One Key Takeaway

info

Choose caching patterns based on consistency requirements, not just performance. Cache-Aside suits eventual consistency; Write-Through guarantees consistency at latency cost; Write-Behind maximizes throughput but risks data loss. Always measure hit rates and profile before tuning TTL.

Next Steps

  1. Measure current hit rates - Add cache metrics to all layers
  2. Profile access patterns - Identify hot keys and access frequency
  3. Simulate cache failures - Test application behavior on cache loss
  4. Tune TTL - Start conservative (5 min), adjust based on freshness needs
  5. Implement invalidation - Add event-driven invalidation for critical data
  6. Monitor evictions - Alert when eviction rate exceeds thresholds
  7. Test failover - Ensure application doesn't fail when cache is unavailable

References