06-caching-locking-performance

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.

August 14, 2026
backend-engineercache-asideconsistencyperformancewrite-throughstampede

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.

java
@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

AspectBehavior
Read pathApp checks cache, falls back to DB on miss, populates cache
Write pathApp writes DB, then deletes (or updates) cache key
ConsistencyEventually consistent — brief staleness possible between DB write and cache invalidation
Failure modeCache down → all reads fall through to DB (graceful degradation)
Cold startFirst request for any key is always a miss (slow path)
Best forRead-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.

java
@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

ScenarioWhy it fits
Reads dominate and must always be fastCache is never cold for previously-written keys
Writes are relatively infrequentThe added write latency (DB + cache) is acceptable
Data is read immediately after being writtenNo "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.

java
@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

StrategyWrite latencyRead latencyConsistency riskData-loss riskComplexity
Cache-asideLow (DB write + delete)First read after write may be slow (cache miss)Brief staleness window between DB write and invalidationNone (DB is always source of truth)Low
Write-throughHigher (DB write + cache write, synchronous)Always fast (cache always warm)Low — cache and DB updated togetherLow, if cache write fails after DB commitMedium
Write-behindLowest (cache write only)Always fastHigher — DB lags behind cacheHigh — unflushed writes are lost on crashHigh

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.

java
@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.

java
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.

java
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.

java
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 patternRoot causeFix
Cache updated before DB commitWriter updates cache, then DB write fails/rolls backWrite DB first, invalidate cache after commit succeeds
Read-after-write shows stale dataReplica lag combined with cache-aside re-populating from a lagging replicaRead from primary immediately after a write, or invalidate + short negative-cache the key
Race between writer and cache-aside repopulationWriter deletes key; concurrent reader repopulates with pre-write (stale) valueDelayed double-delete: delete, then delete again ~500ms later
Partial cache update on multi-field objectOnly some fields updated in cache, rest staleInvalidate the whole object key on any partial write, don't patch in place
Cache never invalidated on deleteDelete path forgets to clear the cache keyAlways 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.

java
@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.

java
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?