Caching Patterns Explained: A Practical Catalogue with Code Examples
Most caching bugs I have debugged were not caused by a bad TTL. They were caused by a team picking the wrong read/write pattern and then bolting workarounds onto it for two years. A cache-aside system pretending to be write-through. A write-behind buffer that quietly dropped the last 40 seconds of writes during a failover.
The pattern you choose decides who reads the cache, who writes it, and what happens when the cache and the database disagree. Get that decision right and the rest is tuning. This is a catalogue of the patterns you will actually meet, with code, and a blunt opinion on when each one earns its complexity.
If you want the layered view first (browser, CDN, application, database), read our companion piece on caching strategies every developer should know. This article goes one level deeper into the read/write mechanics.
How to Read This Catalogue
Every pattern below answers four questions:
- Who reads the cache? The application, or a caching layer that sits transparently in front of the store.
- Who writes the cache? The application explicitly, or the cache automatically.
- When does the database get written? Synchronously on the request, or asynchronously later.
- What happens on a miss? Who fetches the data and repopulates the cache.
The code examples use a generic cache (think Redis) and db client. They are deliberately small so the mechanics are visible, not buried under error handling.
Cache-Aside (Lazy Loading)
The application is in charge. It checks the cache, and only on a miss does it load from the database and write the result back.
async function getUser(id) {
const cached = await cache.get(`user:${id}`);
if (cached) return JSON.parse(cached);
const user = await db.users.findById(id);
if (user) {
await cache.set(`user:${id}`, JSON.stringify(user), { EX: 300 });
}
return user;
}
This is the default for a reason. It only caches data that is actually requested, the cache and database are loosely coupled, and a cache outage means slower reads, not a broken application. The downsides: the first request for any key always misses (cold start), and your read and write paths can drift out of sync if a teammate forgets to invalidate on update.
Use it when: read-heavy workloads where occasional staleness is acceptable. This covers the large majority of web applications.
Read-Through
Read-through moves the miss-handling logic out of the application and into the caching layer. The application asks the cache for data; if it is not there, the cache itself loads from the database and stores the result.
// The cache library is configured with a loader.
const userCache = new ReadThroughCache({
ttl: 300,
loader: (id) => db.users.findById(id),
});
async function getUser(id) {
return userCache.get(id); // miss handling happens inside
}
The application code is cleaner and the loading logic lives in one place. The cost is coupling: your cache now needs to know how to query your database, and you usually depend on a library or provider that supports this (some ORMs and managed caches do). Functionally it serves the same workloads as cache-aside; the difference is where the logic lives.
Use it when: you have many call sites reading the same entities and you want the loading rule defined once, or your caching provider offers it natively.
Write-Through
On every write, the application updates the cache and the database synchronously, in the same request, before responding.
async function updateUser(id, data) {
const user = await db.users.update(id, data);
await cache.set(`user:${id}`, JSON.stringify(user), { EX: 300 });
return user;
}
The cache is never stale relative to the last write, because the two move together. The price is latency: every write now pays for both stores, and you are caching data that may never be read again. Write-through pairs naturally with read-through so reads and writes share the same layer.
Use it when: data is read far more often than written, and reads must reflect the most recent write immediately (user profile shown right after the user edits it).
Write-Behind (Write-Back)
The application writes to the cache and returns immediately. A background process flushes the change to the database later, often batching many writes together.
async function recordView(articleId) {
// Increment in the cache, return instantly.
await cache.incr(`views:${articleId}`);
}
// Separate worker, every 10 seconds:
async function flushViews() {
const keys = await cache.keys('views:*');
for (const key of keys) {
const id = key.split(':')[1];
const count = await cache.getdel(key);
await db.articles.incrementViews(id, Number(count));
}
}
This is the fastest write path because the slow store is off the request entirely, and batching turns thousands of individual writes into a handful of database operations. The danger is durability: if the cache fails before the flush, the buffered writes are gone. Treat anything you cannot afford to lose with care.
Use it when: high-volume writes that tolerate small loss or eventual durability, such as view counters, metrics, and activity logs. Do not use it for orders or payments.
Refresh-Ahead
Refresh-ahead reloads an entry in the background before it expires, so popular keys are renewed without a single read ever hitting a cold cache.
async function getConfig(key) {
const entry = await cache.getWithMeta(key);
if (!entry) return loadAndCache(key);
// If we are within the refresh window, refresh in the background.
const age = Date.now() - entry.storedAt;
if (age > REFRESH_AFTER_MS) {
loadAndCache(key).catch(() => {}); // fire and forget
}
return entry.value; // serve current value now
}
For a small set of hot keys (feature flags, configuration, a homepage feed), this eliminates the periodic latency spike that cache-aside produces every time the TTL lapses. The waste is real: you will refresh keys that are about to go quiet. It only pays off when the access pattern is predictable and concentrated.
Use it when: a small number of expensive, frequently read keys where the post-expiry miss latency is noticeable.
Pattern Comparison
| Pattern | Who reads | Who writes cache | DB write timing | Best for |
|---|---|---|---|---|
| Cache-aside | Application | Application (on miss) | On demand | General read-heavy workloads |
| Read-through | Cache layer | Cache layer (on miss) | On demand | Shared entities, single load rule |
| Write-through | Application | Application (on write) | Synchronous | Read-after-write consistency |
| Write-behind | Application | Application (on write) | Asynchronous | High-volume, loss-tolerant writes |
| Refresh-ahead | Application | Background refresh | Independent | Hot keys, predictable access |
The Mechanism Behind the Patterns: Invalidation
Every pattern above eventually faces the same question: how does a cached value stop being served once it is wrong? There are three mechanisms, and most production systems combine them.
TTL expiry. Each entry carries a time-to-live and is evicted when it lapses. Simple, self-healing, and the reason nothing lives forever. On its own it serves stale data for up to the TTL window.
Explicit invalidation. On every write, delete or overwrite the affected keys.
async function updateProduct(id, data) {
const product = await db.products.update(id, data);
await cache.del(`product:${id}`); // single entity
await cache.del('products:featured'); // any derived view it appears in
return product;
}
The hard part is not deleting the obvious key. It is finding every derived key the change touches: list pages, search results, aggregates. Miss one and you have a stale-data bug that only shows up under specific access orders. The Redis team has a good overview of the cache invalidation approaches ↗ and their trade-offs.
Event-driven invalidation. Instead of every writer remembering every key, publish a change event and let subscribers invalidate. This decouples writers from cache topology and scales better across services, at the cost of an event pipeline to operate. If you are already running an event bus, this is where it pays off; see our guide to event-driven architecture.
Cache Stampede: The Failure Every Pattern Shares
When a hot key expires, hundreds of concurrent requests can all miss at once and stampede the database. A single lock around the recomputation is the cheapest fix:
async function getWithLock(key, loader) {
const cached = await cache.get(key);
if (cached) return JSON.parse(cached);
// Only one caller wins the lock and rebuilds; others briefly wait.
const gotLock = await cache.set(`lock:${key}`, '1', { NX: true, PX: 5000 });
if (!gotLock) {
await sleep(50);
return getWithLock(key, loader); // retry, likely a hit now
}
const value = await loader();
await cache.set(key, JSON.stringify(value), { EX: 300 });
await cache.del(`lock:${key}`);
return value;
}
Probabilistic early expiration (refreshing a key slightly before its TTL, with some randomness) and the stale-while-revalidate approach are the other common defences. The pattern you chose does not exempt you from this: cache-aside, read-through and refresh-ahead all need stampede protection on their hot keys.
Picking a Pattern: A Short Decision Path
- Default to cache-aside. It fits most read-heavy workloads and fails safe.
- Need read-after-write consistency on hot entities? Add write-through so the cache updates with the database.
- Writes are high-volume and loss-tolerant? Use write-behind, and accept the durability trade-off explicitly.
- A handful of expensive hot keys spiking on expiry? Add refresh-ahead for those keys only.
- Many writers struggling to invalidate correctly? Move to event-driven invalidation.
For provider-level guidance on TTLs, eviction and sizing, AWS publishes a solid set of caching best practices ↗ that complements the patterns here.
The Pattern Is a Decision, Not a Default
The mistake is treating caching as a single feature you switch on. It is a set of explicit decisions about read paths, write paths, and how disagreement between cache and source is resolved. Name the pattern you are using, write it down next to the cache, and make sure the invalidation path matches it.
Once your caching is solid, the next bottleneck is usually the database itself. Our guide to database indexing is the natural next read. And if your cached values include timestamps, do not let them bite you: see why time zones break your code.
Frequently asked questions
What is the difference between a caching pattern and a caching strategy?
The terms are often used interchangeably, but a pattern usually describes the concrete read and write mechanics (who reads the cache, who writes it, in what order), while a strategy is the higher-level decision about which pattern to use for a given workload. Cache-aside is a pattern. Choosing cache-aside with a 60-second TTL for your product catalogue is a strategy.
Which caching pattern should I use by default?
Start with cache-aside (also called lazy loading). The application controls reads and writes explicitly, it only caches data that is actually requested, and a cache outage degrades to a slower path rather than an outage. Reach for read-through, write-through, write-behind or refresh-ahead only when you have a specific reason cache-aside cannot satisfy.
What is the difference between write-through and write-behind caching?
Write-through writes to the cache and the database synchronously in the same request, so the data is durable before you respond but every write pays the database cost. Write-behind (write-back) writes to the cache immediately and flushes to the database asynchronously, which is much faster but risks losing recent writes if the cache fails before the flush completes.
How do I avoid serving stale data from a cache?
Combine a short TTL with explicit invalidation on write. Set a TTL as a safety net so nothing lives forever, and delete or update the cache key whenever the underlying data changes. For data that must never be stale, do not cache it: read from the source of truth.
What is refresh-ahead caching?
Refresh-ahead proactively reloads a cache entry before it expires, based on access patterns, so hot keys are refreshed in the background and reads almost never hit a cold cache. It reduces tail latency for popular data but wastes work refreshing entries that are about to stop being requested.
Enjoyed this article? Get more developer tips straight to your inbox.
Comments
Join the conversation. Share your experience or ask a question below.
No comments yet. Be the first to share your thoughts.