I've been studying caching strategies lately, and honestly, I used to think caching was just "put stuff in Redis so your app goes faster." Turns out there's a whole spectrum of patterns, each with different tradeoffs around consistency, latency, and write throughput. Some of these would have saved me a lot of pain in previous projects if I'd known about them earlier.
Here's what I've learned so far.
Why Caching Matters
Every time your application talks to a database, there's a cost: network latency, disk I/O, connection pool pressure, query execution time. When you have thousands of requests per second, those costs add up fast. Caching puts a faster storage layer (usually in-memory, like Redis or Memcached) between your application and the database so you can serve data without hitting the slow path every time.
But the question isn't just "should I cache?" It's how should the cache interact with the database? That's where caching strategies come in.
The Five Main Strategies
Here's a quick overview before we go deep:
| Strategy | Read Path | Write Path | Best For |
|---|---|---|---|
| Cache-Aside | App checks cache, falls back to DB | App writes to DB, invalidates cache | General purpose, read-heavy |
| Read-Through | Cache fetches from DB on miss | N/A (read-only pattern) | Simplifying read logic |
| Write-Through | Paired with read-through | Cache writes to DB synchronously | Strong consistency needed |
| Write-Behind | Paired with read-through | Cache writes to DB asynchronously | High write throughput |
| Write-Around | Cache-aside for reads | App writes directly to DB, skips cache | Write-heavy, rarely re-read |
Now let's break each one down.
1. Cache-Aside (Lazy Loading)
This is the most common pattern and probably the one you've already used without knowing the name. The application is responsible for all cache management.
sequenceDiagram
participant App
participant Cache
participant DB
App->>Cache: GET key
alt Cache Hit
Cache-->>App: Return data
else Cache Miss
Cache-->>App: null
App->>DB: SELECT * FROM table
DB-->>App: Return data
App->>Cache: SET key = data
end
How it works:
- Application checks the cache first
- If the data is there (cache hit), return it
- If not (cache miss), fetch from the database
- Store the result in cache for next time
On writes, the application writes directly to the database and then either invalidates the cache entry or updates it.
// Cache-Aside: Read
async function getUser(userId: string): Promise<User> {
// Step 1: Check cache
const cached = await redis.get(`user:${userId}`);
if (cached) {
return JSON.parse(cached);
}
// Step 2: Cache miss, fetch from DB
const user = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
// Step 3: Populate cache for next time
await redis.set(`user:${userId}`, JSON.stringify(user), "EX", 3600);
return user;
}
// Cache-Aside: Write
async function updateUser(userId: string, data: Partial<User>): Promise<void> {
// Write to DB first
await db.query("UPDATE users SET name = $1 WHERE id = $2", [
data.name,
userId,
]);
// Invalidate cache (next read will repopulate)
await redis.del(`user:${userId}`);
}
Pros:
- Simple to implement
- Only caches data that's actually requested (no wasted memory)
- Application has full control over cache logic
- Cache failure doesn't break the app (just slower)
Cons:
- First request for any key is always a cache miss (cold start)
- Risk of stale data if cache isn't properly invalidated on writes
- Application code has to manage both cache and database
When to use it: read-heavy workloads where you can tolerate the occasional cache miss. This is your default starting point.
2. Read-Through
Similar to cache-aside, but the cache itself is responsible for fetching data from the database on a miss. The application only talks to the cache, never directly to the database for reads.
sequenceDiagram
participant App
participant Cache
participant DB
App->>Cache: GET key
alt Cache Hit
Cache-->>App: Return data
else Cache Miss
Cache->>DB: Fetch data
DB-->>Cache: Return data
Cache->>Cache: Store data
Cache-->>App: Return data
end
How it works:
- Application asks the cache for data
- If cached, return immediately
- If not, the cache (not the application) fetches from the database, stores it, and returns it
// Read-Through: The cache layer handles DB fetching
class ReadThroughCache {
private redis: Redis;
private db: Database;
async get(key: string, loader: () => Promise<unknown>): Promise<unknown> {
const cached = await this.redis.get(key);
if (cached) {
return JSON.parse(cached);
}
// Cache handles the DB fetch
const data = await loader();
await this.redis.set(key, JSON.stringify(data), "EX", 3600);
return data;
}
}
// Application code becomes simpler
const cache = new ReadThroughCache(redis, db);
const user = await cache.get(`user:${userId}`, () =>
db.query("SELECT * FROM users WHERE id = $1", [userId]),
);
Pros:
- Application code is cleaner (no cache management logic scattered around)
- Consistent caching behavior across the app
Cons:
- First request is still a cache miss
- Tighter coupling between cache and data source
- Cache layer becomes more complex
When to use it: when you want to centralize your caching logic instead of repeating cache-aside patterns everywhere. This is basically cache-aside with better separation of concerns.
3. Write-Through
The cache sits in front of the database for writes too. Every write goes to the cache first, and the cache synchronously writes it to the database before confirming.
sequenceDiagram
participant App
participant Cache
participant DB
App->>Cache: Write data
Cache->>DB: Write data (sync)
DB-->>Cache: Confirm
Cache-->>App: Confirm
How it works:
- Application writes to the cache
- Cache immediately writes the same data to the database
- Cache confirms the write only after the database confirms
// Write-Through: Cache handles both cache + DB write
class WriteThroughCache {
async set(
key: string,
value: unknown,
dbWriter: () => Promise<void>,
): Promise<void> {
// Write to DB first (synchronous)
await dbWriter();
// Then update cache
await this.redis.set(key, JSON.stringify(value), "EX", 3600);
}
}
// Usage
await cache.set(`user:${userId}`, updatedUser, () =>
db.query("UPDATE users SET name = $1 WHERE id = $2", [
updatedUser.name,
userId,
]),
);
Pros:
- Cache and database are always in sync (strong consistency)
- No stale data risk
- Reads after writes always hit the cache (no cold miss)
Cons:
- Higher write latency (every write waits for both cache AND database)
- Not ideal for write-heavy workloads
When to use it: when data consistency is critical and you can accept higher write latency. Pair this with read-through for a complete caching solution.
4. Write-Behind (Write-Back)
This is the one that made me wish I'd known about it sooner. The cache absorbs writes immediately and flushes them to the database asynchronously in batches. The application gets a fast write confirmation without waiting for the database.
sequenceDiagram
participant App
participant Cache
participant Queue
participant DB
App->>Cache: Write data
Cache-->>App: Confirm (immediate)
Cache->>Queue: Queue for DB write
Queue->>DB: Batch write (async)
DB-->>Queue: Confirm
How it works:
- Application writes to the cache
- Cache confirms immediately (fast!)
- In the background, the cache queues the write
- A background process flushes queued writes to the database in batches
// Write-Behind: Buffer writes and flush in batches
class WriteBehindCache {
private writeBuffer: Map<string, { value: unknown; timestamp: number }> =
new Map();
private flushInterval: NodeJS.Timer;
constructor(
private redis: Redis,
private db: Database,
) {
// Flush every 5 seconds
this.flushInterval = setInterval(() => this.flush(), 5000);
}
async set(key: string, value: unknown): Promise<void> {
// Write to cache immediately
await this.redis.set(key, JSON.stringify(value), "EX", 3600);
// Buffer for async DB write
this.writeBuffer.set(key, { value, timestamp: Date.now() });
}
private async flush(): Promise<void> {
if (this.writeBuffer.size === 0) return;
const entries = Array.from(this.writeBuffer.entries());
this.writeBuffer.clear();
// Batch write to database
const batch = entries.map(([key, { value }]) => this.writeToDB(key, value));
try {
await Promise.all(batch);
} catch (error) {
// Re-queue failed writes
entries.forEach(([key, entry]) => {
this.writeBuffer.set(key, entry);
});
console.error("Write-behind flush failed, entries re-queued:", error);
}
}
private async writeToDB(key: string, value: unknown): Promise<void> {
// Extract entity type and ID from key pattern
const [entity, id] = key.split(":");
await this.db.query(
`INSERT INTO ${entity} VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET data = $2`,
[id, value],
);
}
}
Pros:
- Extremely fast writes (application doesn't wait for DB)
- Batch writes reduce database load significantly
- Great for absorbing write spikes
Cons:
- Risk of data loss: if the cache crashes before flushing, buffered writes are gone
- Eventual consistency (cache and DB may be temporarily out of sync)
- More complex to implement correctly (error handling, retry logic, ordering)
When to use it: high write throughput scenarios where you can tolerate a small window of potential data loss. Think logging, metrics, analytics, or any situation where writes vastly outnumber reads.
Where Write-Behind Would Have Saved Me
Looking back at my time at Bank BTPN, we had a debit card reconciliation system that ingested data from 16 different sources, each containing anywhere from 30,000 to 600,000 records. The system needed to process these files daily for back-office operations.
At the time, we processed records more or less synchronously: read a batch from the SFTP file, validate, write to the database, repeat. The database became the bottleneck when multiple sources were being ingested simultaneously.
If I'd known about write-behind at the time, we could have:
- Buffered incoming records in Redis as they were parsed from SFTP files
- Flushed to the database in optimized batches (bulk inserts instead of individual writes)
- Decoupled the ingestion speed from database write speed, letting the system absorb all 16 sources concurrently without hammering the DB
We eventually moved to an event-driven architecture with Kafka, which solved the throughput problem in a different way (message queue + consumer groups). But for teams that don't want to introduce a full message broker, write-behind caching can solve a similar problem with less infrastructure.
5. Write-Around
The application writes directly to the database, completely bypassing the cache. The cache is only populated on reads (via cache-aside or read-through).
sequenceDiagram
participant App
participant Cache
participant DB
App->>DB: Write data (bypass cache)
DB-->>App: Confirm
Note over Cache: Cache not updated
App->>Cache: Later: GET key (cache miss)
Cache-->>App: null
App->>DB: Fetch data
DB-->>App: Return data
App->>Cache: Populate cache
How it works:
- Application writes directly to the database
- Cache is not updated or invalidated
- Next read for that key will be a cache miss, fetching fresh data from DB
// Write-Around: Write to DB only, let cache populate on next read
async function createLogEntry(entry: LogEntry): Promise<void> {
// Write directly to DB, skip cache entirely
await db.query("INSERT INTO logs VALUES ($1, $2, $3)", [
entry.id,
entry.message,
entry.timestamp,
]);
// Cache is not touched. If someone reads this entry later,
// cache-aside will populate it.
}
Pros:
- Cache doesn't get polluted with data that may never be read
- Simple write path
- Good for write-heavy data that's rarely queried immediately
Cons:
- Higher read latency for recently written data (guaranteed cache miss)
- Not suitable if you read-after-write frequently
When to use it: when written data is rarely read back immediately. Audit logs, historical records, event streams. You don't want your cache filled with log entries that nobody looks at.
Choosing the Right Strategy
There's no universal answer. It depends on your read/write ratio, consistency requirements, and tolerance for complexity.
Here's a quick-reference table for common scenarios:
| Scenario | Example | Caching Strategy |
|---|---|---|
| Strong Consistency | Financial systems, stock management | Cache-Aside, Read-Through |
| Read-Heavy Workloads | Popular news on websites | Cache-Aside, Read-Through |
| Write-Heavy Workloads | Systems with frequent updates | Write-Around, Write-Behind |
| Data Freshness | Stock trading platforms | Write-Through, Read-Through |
| Static Data | CSS files, images, CDN content | Cache-Aside |
| Frequently Accessed Data | E-commerce stock updates | Write-Through |
And here's a decision flowchart if you prefer to think in terms of tradeoffs:
graph TD
A[What's your workload?] --> B{Read-heavy?}
B -->|Yes| C{Need simple code?}
C -->|Yes| D[Cache-Aside]
C -->|No| E[Read-Through]
B -->|No| F{Write-heavy?}
F -->|Yes| G{Tolerate data loss risk?}
G -->|Yes| H[Write-Behind]
G -->|No| I{Data rarely re-read?}
I -->|Yes| J[Write-Around]
I -->|No| K[Write-Through]
F -->|Balanced| L{Consistency critical?}
L -->|Yes| M[Write-Through + Read-Through]
L -->|No| N[Cache-Aside]
Rules of thumb:
- Start with cache-aside. It's the simplest, most understood pattern. Only move to something else when you hit a specific problem.
- If you need strong consistency: write-through + read-through. Every write updates both cache and DB. Every read hits the cache.
- If you need high write throughput: write-behind. But understand the data loss risk and build retry/recovery mechanisms.
- If most writes are never re-read: write-around. Don't pollute your cache with data nobody asks for.
- You can combine strategies. Use cache-aside for most things, write-behind for high-throughput ingestion, and write-around for audit logs. There's no rule that says your whole system has to use one pattern.
Common Pitfalls
A few things I've learned (some the hard way, some from reading about other people's hard way):
1. Cache invalidation is still the hardest problem. No matter which strategy you pick, getting invalidation right is where bugs hide. When in doubt, set a TTL and let entries expire naturally.
2. Thundering herd on cache miss. When a popular cache key expires, hundreds of requests might simultaneously miss the cache and all hit the database. Solutions: cache warming, request coalescing (only one request fetches, others wait), or staggered TTLs.
3. Serialization overhead. JSON.stringify/parse on every cache operation adds up. Consider using MessagePack or Protocol Buffers for frequently accessed, large objects.
4. Cache size management. An unbounded cache will eat your memory. Use LRU eviction policies, set max memory limits, and monitor hit rates.
Closing Thoughts
Caching isn't just "throw Redis at it." Each strategy makes a deliberate tradeoff between read latency, write latency, consistency, and complexity. Understanding these tradeoffs means you can make an informed choice instead of discovering the wrong one in production at 3 AM.
I'm still learning this stuff, and I'm sure there are edge cases and nuances I haven't run into yet. If you have war stories about caching strategies (especially write-behind in production), I'd love to hear them. Drop me a line at [email protected].