Caching Part 3: Strategies — Read-Through, Write-Through, and Beyond
Caching Deep Dive(9-part series)
- P1Caching Part 1: Fundamentals — Why, When, and When Not to Cache
- P2Caching Part 2: The Landscape — Where to Put Your Cache
- P3Caching Part 3: Strategies — Read-Through, Write-Through, and Beyond(here)
- P4Caching Part 4: Eviction Policies — LRU, LFU, and Making Room
- P5Caching Part 5: Invalidation — The Hard Problem Nobody Solves Well

There are really only 5 ways to keep a cache in sync with your database. Pick the right one, and your system hums. Pick the wrong one, and you'll be debugging stale data at 2 AM.
The Big Picture
1. Cache-Aside (Lazy Loading)
The most common pattern. Your application code manages the cache explicitly.
class ProductService(
private val cache: RedisTemplate<String, Product>,
private val repository: ProductRepository
) {
fun getProduct(id: Long): Product {
val cacheKey = "product:$id"
// 1. Check cache
val cached = cache.opsForValue().get(cacheKey)
if (cached != null) return cached
// 2. Cache miss → fetch from DB
val product = repository.findById(id)
.orElseThrow { ProductNotFoundException(id) }
// 3. Populate cache
cache.opsForValue().set(cacheKey, product, Duration.ofHours(1))
return product
}
}Pros: Simple, explicit, developer has full control. Cons: First request for any key is always a miss. Code is more verbose — every read needs the check-fetch-set pattern.
2. Read-Through
Same as cache-aside, but the cache provider handles the miss logic. Your application only talks to the cache.
@Configuration
class CacheConfig {
@Bean
fun productCache(repository: ProductRepository): LoadingCache<Long, Product> {
return Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofHours(1))
.build { id -> repository.findById(id).orElseThrow() }
// ^^^ The cache loads from DB automatically on miss
}
}
@Service
class ProductService(private val productCache: LoadingCache<Long, Product>) {
fun getProduct(id: Long): Product = productCache.get(id)
// That's it. One line.
}Pros: Cleaner application code. Cache loading logic is centralized. Cons: The cache provider becomes a critical dependency. Harder to customize per-request behavior.
Cache-Aside vs Read-Through? Cache-aside gives you control. Read-through gives you simplicity. For most Spring Boot apps, read-through with Caffeine or Spring Cache is the sweet spot.
3. Write-Through
Every write goes to both the cache and the database synchronously.
@Service
class ProductService(
private val cache: LoadingCache<Long, Product>,
private val repository: ProductRepository
) {
fun updateProduct(id: Long, request: UpdateProductRequest): Product {
// 1. Write to database
val updated = repository.save(request.toEntity(id))
// 2. Update cache (synchronous)
cache.put(id, updated)
return updated
}
}With Spring Cache annotations:
@CachePut(value = ["products"], key = "#result.id")
fun updateProduct(id: Long, request: UpdateProductRequest): Product {
return repository.save(request.toEntity(id))
}Pros: Cache is always consistent with the database. No stale reads. Cons: Write latency = cache write time + DB write time. Slower writes.
4. Write-Behind (Write-Back)
Write to the cache first, acknowledge immediately, then asynchronously flush to the database.
@Service
class ProductService(
private val cache: Cache<Long, Product>,
private val writeQueue: WriteThroughQueue,
private val repository: ProductRepository
) {
fun updateProduct(id: Long, request: UpdateProductRequest): Product {
val updated = request.toEntity(id)
// 1. Update cache immediately
cache.put(id, updated)
// 2. Queue the DB write (async)
writeQueue.enqueue(ProductWriteOperation(id, updated))
return updated
}
}
// Background consumer that batches writes
@Scheduled(fixedDelay = 5000) // Every 5 seconds
fun flushWrites() {
val batch = writeQueue.drain(100) // Up to 100 at a time
if (batch.isNotEmpty()) {
repository.saveAll(batch.map { it.product })
}
}Pros: Very fast write latency. Can batch writes to reduce DB load. Cons: Risk of data loss if the app crashes before flushing. Consistency is delayed.
Use write-behind only when you can tolerate data loss. Analytics events, view counts, non-critical metrics — yes. Financial transactions, inventory — absolutely not.
5. Refresh-Ahead
Proactively refresh cached items before they expire, so users never see a miss.
@Configuration
class CacheConfig {
@Bean
fun productCache(repository: ProductRepository): LoadingCache<Long, Product> {
return Caffeine.newBuilder()
.maximumSize(10_000)
.refreshAfterWrite(Duration.ofMinutes(8)) // Refresh after 8 min
.expireAfterWrite(Duration.ofMinutes(10)) // Hard expiry at 10 min
.build { id -> repository.findById(id).orElseThrow() }
}
}The key insight: refreshAfterWrite < expireAfterWrite. Items get refreshed in the background before the hard expiry. Users always get fresh data without ever waiting for a cache miss.
Pros: Users never experience a cache miss for popular items. Cons: Wastes resources refreshing items nobody is reading. Only makes sense for hot keys.
Decision Matrix
| Pattern | Read Latency | Write Latency | Consistency | Complexity |
|---|---|---|---|---|
| Cache-Aside | Miss on first read | Normal | Eventually consistent | Low |
| Read-Through | Never a miss (auto-load) | Normal | Eventually consistent | Low |
| Write-Through | Always fresh | Slow (double write) | Strong | Medium |
| Write-Behind | Always fresh | Fast (async) | Delayed | High |
| Refresh-Ahead | Never a miss | Normal | Near real-time | Medium |
Quick Decision Guide
- Read-heavy, tolerate stale? → Cache-aside or Read-through
- Need strong consistency? → Write-through
- High write throughput? → Write-behind (if you can tolerate data loss)
- Zero miss tolerance? → Refresh-ahead for hot keys
Key Takeaways
- Cache-aside is the default — simple, explicit, works everywhere
- Read-through cleans up your code by centralizing miss handling
- Write-through ensures consistency at the cost of write speed
- Write-behind maximizes write throughput but risks data loss
- Refresh-ahead eliminates misses for hot keys by proactively refreshing
- Start with cache-aside, upgrade to read-through + write-through as your system matures
Tags
Related Posts

Caching Part 9: Hands-On — Code Labs and Real Implementations
Copy-paste-ready caching implementations — Spring Cache annotations, two-level caching with Caffeine + Redis, and a full Docker Compose setup.

Caching Part 2: The Landscape — Where to Put Your Cache
From browser to database — every layer of your stack has caching opportunities. Learn where each one fits and how they work together.

Caching Part 7: In Production — Monitoring, Sizing, and Staying Alive
The operational side of caching — what to monitor, how to size your cache, high availability setup, and surviving failures gracefully.