Cache-Aside, Write-Through, and Write-Behind Strategies
A staff-engineer guide to cache-aside, write-through, and write-behind patterns, plus stampede mitigation and consistency traps.
Cache-Aside and Read/Write Strategies
Adding Redis to your architecture is the easy part. The hard part is deciding who updates the cache, when, and what happens when the cache and the database briefly disagree. This guide covers the three dominant caching strategies — cache-aside, write-through, and write-behind — along with the failure modes that actually show up in production: cache stampedes, stale reads, and race conditions between writers.
1. The Core Problem: Two Sources of Truth
The moment you introduce a cache, you have two copies of the same data — the database (durable, slow) and the cache (fast, potentially stale). Every caching strategy is really just a policy for how those two copies stay in sync, and every policy makes a different trade-off between consistency, latency, and complexity.
There is no free lunch. Every caching pattern below either accepts a window of staleness, adds write latency, or adds implementation complexity. Picking the right one is a function of your read/write ratio and how expensive a stale read actually is for your domain.
2. Cache-Aside (Lazy Loading)
Cache-aside — also called "lazy loading" — puts the application in charge of both reads and writes. On a read, the app checks the cache first; on a miss, it loads from the database and populates the cache. On a write, the app writes to the database and invalidates (or updates) the cache, rather than the cache ever writing to the database.
@Service
public class ProductService {
private final StringRedisTemplate redisTemplate;
private final ProductRepository productRepository;
private final ObjectMapper objectMapper;
private static final Duration CACHE_TTL = Duration.ofMinutes(15);
public ProductDto getProduct(String productId) {
String cacheKey = "product:" + productId;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return deserialize(cached);
}
// Cache miss — load from source of truth
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
ProductDto dto = ProductDto.from(product);
redisTemplate.opsForValue().set(cacheKey, serialize(dto), CACHE_TTL);
return dto;
}
@Transactional
public void updatePrice(String productId, BigDecimal newPrice) {
productRepository.updatePrice(productId, newPrice);
// Invalidate rather than update — simpler, avoids partial-write races
redisTemplate.delete("product:" + productId);
}
}Invalidate, don't update, on writes. It's tempting to recompute the cached value and SET it directly after a write. But if two writers race, the last SET to land wins — even if it's the older value. Deleting the key on write and letting the next reader repopulate it from the database is simpler and self-correcting.
Cache-aside trade-offs
| Aspect | Behavior |
|---|---|
| Read path | App checks cache, falls back to DB on miss, populates cache |
| Write path | App writes DB, then deletes (or updates) cache key |
| Consistency | Eventually consistent — brief staleness possible between DB write and cache invalidation |
| Failure mode | Cache down → all reads fall through to DB (graceful degradation) |
| Cold start | First request for any key is always a miss (slow path) |
| Best for | Read-heavy workloads where some staleness is tolerable |
3. Write-Through
Write-through moves the write path behind the cache: the application writes to the cache, and the cache (or a wrapping layer) synchronously writes through to the database before acknowledging the write. Reads then always hit a warm cache.
@Service
public class WriteThroughInventoryService {
private final StringRedisTemplate redisTemplate;
private final InventoryRepository inventoryRepository;
@Transactional
public void updateStock(String sku, int newQuantity) {
// 1. Write to the database first — it's the durable source of truth
inventoryRepository.updateQuantity(sku, newQuantity);
// 2. Write through to the cache in the same logical operation
redisTemplate.opsForValue().set(
"inventory:" + sku,
String.valueOf(newQuantity),
Duration.ofHours(1)
);
// If this fails after the DB commit, the cache is stale until TTL expiry —
// acceptable for inventory counts, not for something like account balances.
}
}When write-through makes sense
| Scenario | Why it fits |
|---|---|
| Reads dominate and must always be fast | Cache is never cold for previously-written keys |
| Writes are relatively infrequent | The added write latency (DB + cache) is acceptable |
| Data is read immediately after being written | No "cold cache after write" gap like cache-aside has |
Write-through adds latency to every write because you're paying for both the database write and the cache write before acknowledging. If the cache write fails after the DB commit succeeds, you now have a stale-cache bug, not a missing-cache bug — the key still has an old value under TTL instead of being empty. Guard writes with retries or fall back to deleting the key on cache-write failure so the next read repopulates it correctly.
4. Write-Behind (Write-Back)
Write-behind acknowledges the write as soon as it lands in the cache, then asynchronously flushes to the database later — often batched. It gives the lowest write latency of the three patterns, at the cost of a durability window.
@Component
public class WriteBehindViewCounter {
private final StringRedisTemplate redisTemplate;
private final ViewCountRepository viewCountRepository;
// Fast path — just bump the counter in Redis, return immediately
public void recordView(String articleId) {
redisTemplate.opsForValue().increment("views:pending:" + articleId);
redisTemplate.opsForSet().add("views:dirty-keys", articleId);
}
// Slow path — a scheduled flusher batches pending counts to the DB
@Scheduled(fixedDelay = 5000)
public void flushDirtyCounters() {
Set<String> dirtyIds = redisTemplate.opsForSet().members("views:dirty-keys");
if (dirtyIds == null || dirtyIds.isEmpty()) return;
for (String articleId : dirtyIds) {
String key = "views:pending:" + articleId;
String pending = redisTemplate.opsForValue().getAndSet(key, "0");
long delta = pending == null ? 0 : Long.parseLong(pending);
if (delta > 0) {
viewCountRepository.incrementBy(articleId, delta);
}
redisTemplate.opsForSet().remove("views:dirty-keys", articleId);
}
}
}Write-behind trades durability for latency. If Redis crashes before the buffered writes flush to the database, that data is gone permanently — there is no WAL to replay. Only use write-behind for data where losing a few seconds of updates is acceptable (view counters, analytics events, non-critical metrics), never for financial transactions, orders, or anything requiring an audit trail.
Strategy comparison
| Strategy | Write latency | Read latency | Consistency risk | Data-loss risk | Complexity |
|---|---|---|---|---|---|
| Cache-aside | Low (DB write + delete) | First read after write may be slow (cache miss) | Brief staleness window between DB write and invalidation | None (DB is always source of truth) | Low |
| Write-through | Higher (DB write + cache write, synchronous) | Always fast (cache always warm) | Low — cache and DB updated together | Low, if cache write fails after DB commit | Medium |
| Write-behind | Lowest (cache write only) | Always fast | Higher — DB lags behind cache | High — unflushed writes are lost on crash | High |
5. Cache Stampede (Thundering Herd)
A cache stampede happens when a hot key expires (or the cache is cold-started) and a burst of concurrent requests all miss simultaneously, all fall through to the database at once, and hammer it with duplicate identical queries.
This is one of the most common causes of "the database fell over right after a deploy." A popular key (a trending product, a viral post, a widely-shared session config) expires, and thousands of concurrent requests all miss the cache at the same instant, all issuing the same expensive query against the database within milliseconds of each other.
Mitigation 1: Mutex / lock on cache population
Only one request repopulates the cache; the rest wait or serve stale data.
@Service
public class StampedeSafeProductService {
private final StringRedisTemplate redisTemplate;
private final ProductRepository productRepository;
public ProductDto getProduct(String productId) {
String cacheKey = "product:" + productId;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return deserialize(cached);
}
String lockKey = "lock:populate:" + productId;
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(5));
if (Boolean.TRUE.equals(acquired)) {
try {
// We won the race — load from DB and repopulate the cache
ProductDto dto = loadFromDatabase(productId);
redisTemplate.opsForValue()
.set(cacheKey, serialize(dto), Duration.ofMinutes(15));
return dto;
} finally {
redisTemplate.delete(lockKey);
}
} else {
// Someone else is repopulating — brief backoff-and-retry against cache
return retryReadWithBackoff(cacheKey);
}
}
}Mitigation 2: Probabilistic early expiry (XFetch)
Instead of a hard TTL cliff, individual requests probabilistically decide to refresh the cache before it expires, spreading recomputation over time rather than concentrating it at expiry.
public ProductDto getProductWithEarlyRefresh(String productId) {
String cacheKey = "product:" + productId;
CachedValue cached = getCachedWithMetadata(cacheKey); // value + computedAt + ttl
if (cached != null) {
double delta = computeRecomputeTime(cached); // e.g. beta * recompute_ms
double random = -delta * Math.log(ThreadLocalRandom.current().nextDouble());
boolean shouldEarlyRefresh =
(System.currentTimeMillis() - random) >= cached.expiresAt();
if (!shouldEarlyRefresh) {
return cached.value();
}
// else: fall through and recompute now, before a hard expiry causes a stampede
}
return recomputeAndCache(productId, cacheKey);
}Mitigation 3: TTL jitter
The simplest fix — never set the exact same TTL on many related keys populated at the same time, so they don't all expire in the same instant.
public Duration jitteredTtl(Duration baseTtl) {
long jitterMillis = ThreadLocalRandom.current()
.nextLong(0, baseTtl.toMillis() / 10); // up to 10% jitter
return baseTtl.plusMillis(jitterMillis);
}Jitter is cheap insurance. If you bulk-warm a cache (e.g., on deploy or after a Redis failover), staggering TTLs by even 5-10% spreads out the eventual re-population load instead of creating a synchronized stampede minutes or hours later.
Mitigation 4: Serve stale-while-revalidate
Keep serving the last known value past its nominal TTL while one request refreshes it in the background — never let readers see a hard miss for a key that recently had data.
public ProductDto getStaleWhileRevalidate(String productId) {
CachedValue cached = getCachedWithMetadata("product:" + productId);
if (cached == null) {
return recomputeAndCache(productId, "product:" + productId); // true cold start
}
if (cached.isFresh()) {
return cached.value();
}
// Stale but present — serve it immediately, refresh asynchronously
asyncRefreshExecutor.submit(() -> recomputeAndCache(productId, "product:" + productId));
return cached.value();
}6. Avoiding Stale-Data Consistency Bugs
Beyond stampedes, the everyday risk with any cache is staleness that slips through unnoticed — a user sees their old profile photo, a price change doesn't propagate, a canceled order still shows as active.
Common consistency bugs and fixes
| Bug pattern | Root cause | Fix |
|---|---|---|
| Cache updated before DB commit | Writer updates cache, then DB write fails/rolls back | Write DB first, invalidate cache after commit succeeds |
| Read-after-write shows stale data | Replica lag combined with cache-aside re-populating from a lagging replica | Read from primary immediately after a write, or invalidate + short negative-cache the key |
| Race between writer and cache-aside repopulation | Writer deletes key; concurrent reader repopulates with pre-write (stale) value | Delayed double-delete: delete, then delete again ~500ms later |
| Partial cache update on multi-field object | Only some fields updated in cache, rest stale | Invalidate the whole object key on any partial write, don't patch in place |
| Cache never invalidated on delete | Delete path forgets to clear the cache key | Always pair every mutation path (create/update/delete) with the same invalidation call |
The delayed double-delete pattern
This directly addresses a subtle cache-aside race: a reader can repopulate the cache with a stale value between your DB write and your cache delete.
@Async
public void delayedDoubleDelete(String cacheKey) {
redisTemplate.delete(cacheKey);
try {
Thread.sleep(600); // long enough to outlast the race window + replica lag
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
redisTemplate.delete(cacheKey);
}This pattern is a mitigation, not a guarantee. It narrows the race window significantly but doesn't eliminate it entirely under pathological timing. For data where staleness is genuinely unacceptable (pricing at checkout, account balances), don't cache it — or cache it with a very short TTL and read from source at the moment it matters.
7. Negative Caching
Don't forget to cache misses, not just hits. Without negative caching, a request for a non-existent key (a typo'd product ID, a deleted resource someone still has bookmarked) falls through to the database on every single request — an easy vector for accidental or malicious load.
public ProductDto getProduct(String productId) {
String cacheKey = "product:" + productId;
String cached = redisTemplate.opsForValue().get(cacheKey);
if ("__NOT_FOUND__".equals(cached)) {
throw new ProductNotFoundException(productId);
}
if (cached != null) {
return deserialize(cached);
}
Optional<Product> product = productRepository.findById(productId);
if (product.isEmpty()) {
// Cache the negative result with a short TTL
redisTemplate.opsForValue().set(cacheKey, "__NOT_FOUND__", Duration.ofMinutes(2));
throw new ProductNotFoundException(productId);
}
ProductDto dto = ProductDto.from(product.get());
redisTemplate.opsForValue().set(cacheKey, serialize(dto), Duration.ofMinutes(15));
return dto;
}Keep negative-cache TTLs short. You want to avoid hammering the DB for a nonexistent key, but you also don't want to mask a resource becoming available (e.g. eventual-consistency creation) for too long. A minute or two is typically enough to absorb bursty repeated misses.
Key takeaways
- Cache-aside is the default choice for most services — simple, self-healing, and it degrades gracefully if Redis goes down (reads fall through to the DB).
- Write-through trades write latency for always-warm reads. Use it when reads must never be cold and writes are infrequent enough to absorb the extra hop.
- Write-behind is the fastest write path but the least durable — reserve it for data where losing a few seconds of updates on a crash is genuinely acceptable.
- Always invalidate, don't blindly overwrite, cache entries on write — deletion is simpler to reason about and avoids stale-value races from concurrent writers.
- Cache stampedes are a real production incident pattern — mitigate hot-key expiry with a populate-lock, TTL jitter, probabilistic early refresh, or stale-while-revalidate.
- The delayed double-delete pattern closes (not eliminates) the race between a DB write, cache invalidation, and a concurrent reader repopulating stale data.
- Cache misses deserve caching too — negative caching with a short TTL protects the database from repeated lookups of nonexistent keys.
- Every strategy is a consistency/latency/complexity trade-off — pick based on your actual read/write ratio and how expensive staleness is for that specific piece of data, not uniformly across your whole system.
Interview Questions
- Walk through the cache-aside pattern end to end — what happens on a read, and what happens on a write?
- Why should a cache-aside write path delete the cache key instead of updating it directly?
- What is write-through caching, and what's the main cost compared to cache-aside?
- When would write-behind caching be appropriate, and what's the biggest risk it introduces?
- What is a cache stampede / thundering herd, and what real production symptom does it cause?
- Describe at least two different techniques for mitigating a cache stampede on a hot key.
- What is TTL jitter, and why does it help even outside of stampede scenarios?
- Explain the "delayed double-delete" pattern — what race condition is it solving, and why isn't it a complete guarantee?
- What is negative caching, and why does skipping it expose your database to unnecessary load?
- How would you design a caching strategy for product prices that must never be stale at checkout, but can tolerate staleness in a product listing page?
- What happens to your system's consistency guarantees if the cache write in a write-through pattern fails after the database commit succeeds?
- How does read replica lag interact badly with cache-aside repopulation, and how would you mitigate it?
- If Redis goes down entirely, how does your system behave under each of the three caching strategies?