🎯 Cache-Aside is King
Most applications use cache-aside. It’s flexible, understandable, and gives you control.
Imagine you’re a librarian. Every time someone asks for a book, you could walk to the massive warehouse (database) to find it. Or, you could keep the 100 most popular books on a cart right next to you (cache). When someone asks for a popular book, you grab it instantly. That’s caching.
Caching is storing frequently accessed data in fast storage (usually memory) to avoid slow operations like database queries or external API calls.
| Problem | How Caching Solves It |
|---|---|
| Slow Database Queries | Cache stores results, avoiding repeated queries |
| High Database Load | Reduces database requests by 90%+ |
| Expensive External APIs | Cache API responses, avoid rate limits |
| Repeated Computations | Cache expensive calculation results |
| Geographic Latency | Cache data closer to users (CDN) |
There are four main ways to integrate caching into your application. Each has different trade-offs.
The most common pattern. Your application manages the cache directly.
How it works:
When to use:
Trade-offs:
The cache acts as a proxy. Your application only talks to the cache; the cache handles database access.
How it works:
When to use:
Trade-offs:
Writes go to both cache and database simultaneously. Ensures they stay in sync.
How it works:
When to use:
Trade-offs:
Write to cache immediately, database write happens later. Fastest writes, but risky.
How it works:
When to use:
Trade-offs:
At the code level, caching patterns translate to decorator patterns and repository abstractions.
The decorator pattern is perfect for adding caching to existing repositories:
from functools import wrapsfrom typing import Callable, Anyimport time
class CacheDecorator: def __init__(self, cache: dict, ttl: int = 300): self.cache = cache self.ttl = ttl # Time to live in seconds
def __call__(self, func: Callable) -> Callable: @wraps(func) def wrapper(*args, **kwargs): # Create cache key from function args cache_key = f"{func.__name__}:{args}:{kwargs}"
# Check cache (cache-aside pattern) if cache_key in self.cache: cached_data, timestamp = self.cache[cache_key] if time.time() - timestamp < self.ttl: return cached_data
# Cache miss - fetch from source result = func(*args, **kwargs)
# Store in cache self.cache[cache_key] = (result, time.time()) return result
return wrapper
# Usagecache = {}@CacheDecorator(cache, ttl=300)def get_user(user_id: int): # Simulate database query return {"id": user_id, "name": "John"}import java.util.Map;import java.util.concurrent.ConcurrentHashMap;import java.util.function.Function;
public class CacheDecorator<T, R> { private final Map<String, CacheEntry<R>> cache; private final long ttlMillis;
public CacheDecorator(long ttlSeconds) { this.cache = new ConcurrentHashMap<>(); this.ttlMillis = ttlSeconds * 1000; }
public R apply(String key, Function<T, R> function, T input) { // Check cache (cache-aside pattern) CacheEntry<R> entry = cache.get(key); if (entry != null && !entry.isExpired()) { return entry.value; }
// Cache miss - fetch from source R result = function.apply(input);
// Store in cache cache.put(key, new CacheEntry<>(result, System.currentTimeMillis())); return result; }
private static class CacheEntry<R> { final R value; final long timestamp;
CacheEntry(R value, long timestamp) { this.value = value; this.timestamp = timestamp; }
boolean isExpired() { return System.currentTimeMillis() - timestamp > CacheDecorator.this.ttlMillis; } }}interface CacheEntry<T> { value: T; timestamp: number;}
class CacheDecorator<T, R> { private cache: Map<string, CacheEntry<R>>; private ttl: number;
constructor(ttlSeconds: number) { this.cache = new Map(); this.ttl = ttlSeconds * 1000; }
apply(key: string, fn: (input: T) => R, input: T): R { const entry = this.cache.get(key); if (entry && !this.isExpired(entry)) { return entry.value; }
const result = fn(input); this.cache.set(key, { value: result, timestamp: Date.now() });
return result; }
private isExpired(entry: CacheEntry<R>): boolean { return Date.now() - entry.timestamp > this.ttl; }}#include <unordered_map>#include <chrono>#include <string>#include <functional>
template<typename T, typename R>class CacheDecorator {private: struct CacheEntry { R value; std::chrono::steady_clock::time_point timestamp; };
std::unordered_map<std::string, CacheEntry> cache; std::chrono::milliseconds ttl;
bool isExpired(const CacheEntry& entry) const { auto now = std::chrono::steady_clock::now(); return (now - entry.timestamp) > ttl; }
public: CacheDecorator(int ttlSeconds) : ttl(std::chrono::seconds(ttlSeconds)) {}
R apply(const std::string& key, std::function<R(T)> fn, T input) { auto it = cache.find(key); if (it != cache.end() && !isExpired(it->second)) { return it->second.value; }
R result = fn(input); cache[key] = { result, std::chrono::steady_clock::now() };
return result; }};using System;using System.Collections.Generic;
public class CacheDecorator<T, R> { private readonly Dictionary<string, CacheEntry<R>> cache; private readonly TimeSpan ttl;
private class CacheEntry<TValue> { public TValue Value { get; } public DateTime Timestamp { get; }
public CacheEntry(TValue value, DateTime timestamp) { Value = value; Timestamp = timestamp; } }
public CacheDecorator(int ttlSeconds) { this.cache = new Dictionary<string, CacheEntry<R>>(); this.ttl = TimeSpan.FromSeconds(ttlSeconds); }
public R Apply(string key, Func<T, R> fn, T input) { if (cache.TryGetValue(key, out var entry) && !IsExpired(entry)) { return entry.Value; }
var result = fn(input); cache[key] = new CacheEntry<R>(result, DateTime.UtcNow);
return result; }
private bool IsExpired(CacheEntry<R> entry) { return DateTime.UtcNow - entry.Timestamp > ttl; }}A more complete example showing cache-aside in a repository:
from abc import ABC, abstractmethodfrom typing import Optional
class UserRepository(ABC): @abstractmethod def get_user(self, user_id: int) -> Optional[dict]: pass
class DatabaseUserRepository(UserRepository): def get_user(self, user_id: int) -> Optional[dict]: # Simulate database query return {"id": user_id, "name": "John"}
class CachedUserRepository(UserRepository): def __init__(self, db_repo: UserRepository, cache: dict): self.db_repo = db_repo self.cache = cache
def get_user(self, user_id: int) -> Optional[dict]: # Cache-aside pattern cache_key = f"user:{user_id}"
# Check cache first if cache_key in self.cache: return self.cache[cache_key]
# Cache miss - fetch from DB user = self.db_repo.get_user(user_id)
# Store in cache if user: self.cache[cache_key] = user
return userimport java.util.Optional;import java.util.Map;
public interface UserRepository { Optional<User> getUser(int userId);}
class DatabaseUserRepository implements UserRepository { public Optional<User> getUser(int userId) { // Simulate database query return Optional.of(new User(userId, "John")); }}
class CachedUserRepository implements UserRepository { private final UserRepository dbRepo; private final Map<String, User> cache;
public CachedUserRepository(UserRepository dbRepo, Map<String, User> cache) { this.dbRepo = dbRepo; this.cache = cache; }
public Optional<User> getUser(int userId) { // Cache-aside pattern String cacheKey = "user:" + userId;
// Check cache first if (cache.containsKey(cacheKey)) { return Optional.of(cache.get(cacheKey)); }
// Cache miss - fetch from DB Optional<User> user = dbRepo.getUser(userId);
// Store in cache user.ifPresent(u -> cache.put(cacheKey, u));
return user; }}interface User { id: number; name: string;}
interface UserRepository { getUser(userId: number): User | null;}
class DatabaseUserRepository implements UserRepository { getUser(userId: number): User | null { // Simulate database query return { id: userId, name: "John" }; }}
class CachedUserRepository implements UserRepository { private dbRepo: UserRepository; private cache: Map<string, User>;
constructor(dbRepo: UserRepository, cache: Map<string, User>) { this.dbRepo = dbRepo; this.cache = cache; }
getUser(userId: number): User | null { const cacheKey = `user:${userId}`;
// Check cache first if (this.cache.has(cacheKey)) { return this.cache.get(cacheKey)!; }
// Cache miss - fetch from DB const user = this.dbRepo.getUser(userId);
// Store in cache if (user) { this.cache.set(cacheKey, user); }
return user; }}#include <optional>#include <unordered_map>#include <string>
struct User { int id; std::string name;};
class UserRepository {public: virtual std::optional<User> getUser(int userId) = 0;};
class DatabaseUserRepository : public UserRepository {public: std::optional<User> getUser(int userId) override { // Simulate database query return User{userId, "John"}; }};
class CachedUserRepository : public UserRepository {private: UserRepository* dbRepo; std::unordered_map<std::string, User> cache;
public: CachedUserRepository(UserRepository* dbRepo) : dbRepo(dbRepo) {}
std::optional<User> getUser(int userId) override { std::string cacheKey = "user:" + std::to_string(userId);
// Check cache first auto it = cache.find(cacheKey); if (it != cache.end()) { return it->second; }
// Cache miss - fetch from DB auto user = dbRepo->getUser(userId);
// Store in cache if (user.has_value()) { cache[cacheKey] = user.value(); }
return user; }};using System;using System.Collections.Generic;
public class User { public int Id { get; set; } public string Name { get; set; }}
public interface IUserRepository { User GetUser(int userId);}
public class DatabaseUserRepository : IUserRepository { public User GetUser(int userId) { // Simulate database query return new User { Id = userId, Name = "John" }; }}
public class CachedUserRepository : IUserRepository { private readonly IUserRepository dbRepo; private readonly Dictionary<string, User> cache;
public CachedUserRepository(IUserRepository dbRepo) { this.dbRepo = dbRepo; this.cache = new Dictionary<string, User>(); }
public User GetUser(int userId) { var cacheKey = $"user:{userId}";
// Check cache first if (cache.TryGetValue(cacheKey, out var cachedUser)) { return cachedUser; }
// Cache miss - fetch from DB var user = dbRepo.GetUser(userId);
// Store in cache if (user != null) { cache[cacheKey] = user; }
return user; }}Understanding how major companies implement caching patterns helps illustrate when to use each approach:
The Challenge: Facebook’s news feed serves personalized content to billions of users. Each user’s feed is unique, requiring complex queries across multiple data sources.
The Solution: Facebook uses cache-aside pattern extensively:
Why Cache-Aside? Different users need different caching strategies. Some users have high engagement (shorter TTL), others are casual (longer TTL). Cache-aside gives Facebook flexibility to customize per user.
Impact: Reduces database load by 90%+. A typical feed request that would take 500ms from database takes 5ms from cache.
The Challenge: Amazon’s product catalog is accessed millions of times per second. Product data changes infrequently but needs to be fast.
The Solution: Amazon uses read-through caching:
Why Read-Through? Simpler application code. Product service doesn’t need to know about cache - it just reads products. Cache layer handles everything.
Impact: Product pages load in 50ms instead of 200ms. During Prime Day, caching handles 10x normal traffic without database overload.
The Challenge: Trading platforms need real-time, accurate prices. Stale data means wrong trades, which costs money.
The Solution: Trading systems use write-through:
Why Write-Through? Strong consistency is critical. A 1-second delay showing wrong price could mean millions in losses. Write-through ensures cache and database always match.
Example: A stock price update from $100 to $105:
The Challenge: Twitter generates billions of tweets per day. Each tweet needs analytics (views, likes, retweets) tracked, but write performance is critical.
The Solution: Twitter uses write-behind for analytics:
Why Write-Behind? Write performance is critical. Users expect instant tweet posting. Analytics can be eventually consistent - losing a few view counts is acceptable.
Impact: Tweet creation latency: 10ms (cache write) vs 100ms (database write). During viral events, write-behind handles 100x normal write volume.
The Challenge: Netflix serves video metadata (titles, descriptions, ratings) globally. Data changes rarely but needs to be fast worldwide.
The Solution: Netflix uses multiple patterns:
Why Multiple Patterns? Different data has different requirements. Metadata can be stale, recommendations are personalized, watch history must be accurate.
Impact: 95% of requests served from cache. Global latency reduced from 200ms to 20ms average.
| Pattern | Read Latency | Write Latency | Consistency | Complexity | Use Case |
|---|---|---|---|---|---|
| Cache-Aside | Low (cache hit) | Low | Eventual | Medium | Most applications |
| Read-Through | Low (cache hit) | Low | Eventual | Low | Read-heavy apps |
| Write-Through | Low (cache hit) | High (waits for DB) | Strong | Medium | Critical data |
| Write-Behind | Low (cache hit) | Very Low | Eventual | High | High write volume |
🎯 Cache-Aside is King
Most applications use cache-aside. It’s flexible, understandable, and gives you control.
⚡ Speed Matters
Cache lookups are 100x faster than database queries. At scale, this difference is massive.
🔄 Consistency Trade-offs
Faster writes (write-behind) = weaker consistency. Stronger consistency (write-through) = slower writes.
🏗️ Decorator Pattern
Use decorator pattern in code to add caching transparently to existing repositories.