A cache strategy determines how an application reads, writes, and refreshes cached data. I used to describe caching as "put stuff in Redis so your app goes faster." I later learned how the strategy affects consistency, latency, and write throughput.
Why Caching Matters
Every time your application talks to a database, there is 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.
The application needs a rule for how the cache and database interact.
The Five Main Strategies
The main strategies are:
| 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 |
1. Cache Aside (Lazy Loading)
With cache aside, the application manages cache reads, population, and invalidation.
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 is requested (no wasted memory)
- Application has full control over cache logic
- Cache failure does not break the app (just slower)
Cons:
- First request for any key is always a cache miss (cold start)
- Risk of stale data if cache is not 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. Use it as a 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. It is 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:
- A successful write updates both stores when all writers use this path and the implementation handles partial failures
- Coordinated writes can reduce stale reads, but concurrency and failure handling still require a defined consistency policy
- A later read can use the cached value unless the entry expires or the cache evicts it
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)
Write behind 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:
- Lower write latency (the application does not wait for the DB)
- Batch writes can reduce database load
- Useful 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.
A possible alternative for batch ingestion
At Bank BTPN, we had a debit card reconciliation system that received data from 16 different sources. Each source contained 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 we imported data from several sources at the same time.
A possible alternative was to buffer the records before database writes:
- 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)
- Separated ingestion from database writes so that all 16 sources could submit work independently, within the buffer capacity
We eventually used Kafka and consumer groups to address the throughput problem. The cache alternative would require durable buffering and a recovery process for accepted reconciliation records. It would also need a workload test before we could compare its capacity.
5. Write Around
The application writes directly to the database, bypassing the cache. The cache is only populated on reads (through 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
- Do not populate the cache on a write. Invalidate an existing entry if the read path requires fresh data.
- On a cache miss, read the current value from the database.
// 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 does not get polluted with data that may never be read
- Simple write path
- Good for write heavy data that is rarely queried immediately
Cons:
- A read after a write may need a database request when no valid cached entry exists
- 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 do not want your cache filled with log entries that nobody looks at.
Choosing the Right Strategy
There is no universal answer. It depends on your read/write ratio, consistency requirements, and tolerance for complexity.
The following table groups use cases, but the pattern name alone does not guarantee consistency. Define the allowed staleness, invalidation behavior, and concurrent write handling. The Azure cache aside guidance explains this limitation:
| 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 |
The decision flowchart is:
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 is the simplest, most understood pattern. Only move to something else when you hit a specific problem.
- If you need strong consistency: define atomicity, concurrent access, and failure handling for the complete read and write path. A cache pattern alone is insufficient.
- 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. Do not 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 is no rule that says your whole system has to use one pattern.
Common Pitfalls
I check these failure cases when I choose a cache strategy:
1. Define invalidation. Set a time to live (TTL) that matches the allowed staleness. Expiration limits stale data duration but does not guarantee immediate consistency.
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
Choose a strategy from the read and write requirements. Then test cache misses, concurrent writes, expiration, and recovery. These cases determine whether the cache meets the required consistency and latency.
Contact: [email protected].

Loading comments...