Caching Strategies: A Complete Guide to Performance Optimization
Master caching strategies including cache-aside, write-through, write-back, and more. Learn when to use each pattern for optimal performance.
This article is also available in Portuguese
Why Caching Matters
Caching is one of the most effective ways to improve application performance. A well-implemented cache can reduce:
- Database load by 80-90%
- API response times from seconds to milliseconds
- Infrastructure costs significantly
Popular Caching Strategies
1. Cache-Aside (Lazy Loading)
The application is responsible for loading data into the cache.
@Service
class UserService(
private val cacheManager: CacheManager,
private val userRepository: UserRepository
) {
fun getUser(userId: String): User? {
val cache = cacheManager.getCache("users")
// Try cache first
return cache?.get(userId, User::class.java) ?: run {
// Cache miss - load from database
val user = userRepository.findById(userId).orElse(null)
// Store in cache for next time
user?.let { cache?.put(userId, it) }
user
}
}
}
Pros:
- Only requested data is cached
- Resilient to cache failures
- Simple to implement
Cons:
- Initial request is slow (cache miss)
- Cache and database can become inconsistent
Best for: Read-heavy workloads with unpredictable access patterns
2. Read-Through
Cache sits between application and database. Cache automatically loads data on miss.
// Cache configuration with automatic loading
@Configuration
@EnableCaching
class CacheConfig {
@Bean
fun cacheManager(): CacheManager {
return CaffeineCacheManager("users").apply {
setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(3600, TimeUnit.SECONDS)
.maximumSize(1000))
}
}
}
// Application code is simpler
@Service
class UserService(private val userRepository: UserRepository) {
@Cacheable(value = ["users"], key = "#userId")
fun getUser(userId: String): User? {
return userRepository.findById(userId).orElse(null)
}
}
Pros:
- Cleaner application code
- Centralized cache logic
Cons:
- Tighter coupling with data source
- Less control over cache population
Best for: Applications with well-defined data access patterns
3. Write-Through
Data is written to cache and database simultaneously.
@Service
class UserService(
private val userRepository: UserRepository,
private val cacheManager: CacheManager
) {
@CachePut(value = ["users"], key = "#userId")
fun updateUser(userId: String, data: User): User {
// Update database
val updated = userRepository.save(data.copy(id = userId))
// Cache is automatically updated with @CachePut
return updated
}
}
Pros:
- Cache is always consistent
- No cache misses for writes
Cons:
- Higher write latency
- Unnecessary cache entries for rarely-read data
Best for: Applications requiring strong consistency
4. Write-Back (Write-Behind)
Data is written to cache first, then asynchronously to database.
@Service
class UserService(
private val cacheManager: CacheManager,
private val writeQueue: Queue<WriteOperation>,
@Async private val asyncExecutor: Executor
) {
fun updateUser(userId: String, data: User): User {
// Write to cache immediately
val cache = cacheManager.getCache("users")
cache?.put(userId, data)
// Queue for async database write
writeQueue.offer(WriteOperation(
table = "users",
id = userId,
data = data
))
return data
}
}
// Background worker processes queue
@Component
class WriteBackWorker(
private val writeQueue: Queue<WriteOperation>,
private val userRepository: UserRepository
) {
@Scheduled(fixedDelay = 100)
fun processWrites() {
writeQueue.poll()?.let { operation ->
when (operation.table) {
"users" -> userRepository.save(operation.data as User)
}
}
}
}
data class WriteOperation(
val table: String,
val id: String,
val data: Any
)
Pros:
- Very fast writes
- Reduces database load
- Can batch writes
Cons:
- Risk of data loss if cache fails
- Complex to implement
- Eventual consistency
Best for: Write-heavy workloads where some data loss is acceptable
5. Refresh-Ahead
Cache proactively refreshes data before expiration.
@Component
class RefreshAheadCache(
private val redisTemplate: RedisTemplate<String, Any>,
private val userRepository: UserRepository,
@Async private val executor: Executor
) {
private val maxTTL = 3600L // seconds
suspend fun get(key: String): User? {
val data = redisTemplate.opsForValue().get(key) as? User
val ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS)
// If TTL is less than 20% remaining, refresh
if (ttl > 0 && ttl < maxTTL * 0.2) {
refreshAsync(key)
}
return data
}
@Async
fun refreshAsync(key: String) = GlobalScope.launch {
// Non-blocking refresh
val userId = key.removePrefix("user:")
val fresh = userRepository.findById(userId).orElse(null)
fresh?.let {
redisTemplate.opsForValue().set(
key,
it,
maxTTL,
TimeUnit.SECONDS
)
}
}
}
Best for: Predictable, frequently-accessed data
Cache Invalidation Strategies
“There are only two hard things in Computer Science: cache invalidation and naming things.” — Phil Karlton
Time-Based Expiration (TTL)
// Short TTL for frequently changing data
redisTemplate.opsForValue().set(
"stock_price",
price,
60,
TimeUnit.SECONDS
) // 1 minute
// Longer TTL for stable data
redisTemplate.opsForValue().set(
"user_profile",
profile,
3600,
TimeUnit.SECONDS
) // 1 hour
Event-Based Invalidation
@Service
class UserService(
private val userRepository: UserRepository,
private val cacheManager: CacheManager,
private val eventPublisher: ApplicationEventPublisher
) {
@CacheEvict(value = ["users"], key = "#userId")
fun updateUser(userId: String, data: User): User {
val updated = userRepository.save(data)
// Publish event for distributed systems
eventPublisher.publishEvent(
UserUpdatedEvent(this, userId)
)
return updated
}
}
data class UserUpdatedEvent(
val source: Any,
val userId: String
) : ApplicationEvent(source)
Tag-Based Invalidation
@Service
class TaggedCacheService(private val redisTemplate: RedisTemplate<String, Any>) {
// Tag related cache entries
fun setWithTags(key: String, value: Any, tags: List<String>, ttl: Long) {
redisTemplate.opsForValue().set(key, value, ttl, TimeUnit.SECONDS)
// Associate key with tags
tags.forEach { tag ->
redisTemplate.opsForSet().add("tag:$tag", key)
}
}
// Invalidate all caches related to a tag
fun deleteByTag(tag: String) {
val keys = redisTemplate.opsForSet().members("tag:$tag") ?: emptySet()
keys.forEach { key ->
redisTemplate.delete(key as String)
}
redisTemplate.delete("tag:$tag")
}
}
// Usage
taggedCacheService.setWithTags(
"user:123",
userData,
listOf("user", "user:123"),
3600
)
taggedCacheService.deleteByTag("user:123")
Choosing Cache TTL
| Data Type | TTL | Reasoning |
|---|---|---|
| User sessions | 30 min - 2 hours | Balance security and UX |
| User profiles | 1 - 24 hours | Changes infrequently |
| Product catalog | 5 - 60 minutes | Updates periodically |
| Real-time data | 10 - 60 seconds | Needs to be fresh |
| Static content | 7 - 30 days | Rarely changes |
Common Caching Anti-Patterns
❌ Cache Everything
Not all data should be cached. Don’t cache:
- Data that changes frequently
- Large objects that won’t fit in memory
- Rarely accessed data
- Sensitive data without encryption
❌ Ignoring Cache Stampede
Multiple requests hitting database when cache expires simultaneously.
Solution: Cache Locking
@Service
class CacheService(
private val redisTemplate: RedisTemplate<String, Any>,
private val userRepository: UserRepository
) {
fun getWithLock(key: String): User? {
// Check cache first
redisTemplate.opsForValue().get(key)?.let {
return it as User
}
val lockKey = "lock:$key"
val lockAcquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS) ?: false
return if (lockAcquired) {
try {
// This request loads data
val userId = key.removePrefix("user:")
val data = userRepository.findById(userId).orElse(null)
data?.let {
redisTemplate.opsForValue().set(
key,
it,
3600,
TimeUnit.SECONDS
)
}
data
} finally {
redisTemplate.delete(lockKey)
}
} else {
// Wait for other request to populate cache
Thread.sleep(100)
getWithLock(key)
}
}
}
❌ Not Monitoring Cache Hit Rate
Track your cache performance:
@Service
class CacheService(
private val cacheManager: CacheManager,
private val userRepository: UserRepository,
private val meterRegistry: MeterRegistry
) {
fun getWithMetrics(key: String): User? {
val cache = cacheManager.getCache("users")
val data = cache?.get(key, User::class.java)
return if (data != null) {
meterRegistry.counter("cache.hit", "cache", "users").increment()
data
} else {
meterRegistry.counter("cache.miss", "cache", "users").increment()
loadFromDatabase(key)
}
}
private fun loadFromDatabase(key: String): User? {
val userId = key.removePrefix("user:")
return userRepository.findById(userId).orElse(null)
}
}
Aim for 80%+ hit rate for effective caching.
Multi-Layer Caching
Combine multiple cache layers for optimal performance:
Browser Cache (L1)
↓
CDN Cache (L2)
↓
Application Cache (L3) → Redis
↓
Database Query Cache (L4)
↓
Database
Tools and Technologies
Redis
The most popular in-memory cache. Supports:
- Multiple data structures
- Pub/sub
- Persistence
- Clustering
Memcached
Simple, high-performance distributed cache.
CDN Caching
CloudFlare, Fastly, CloudFront for static assets.
Application-Level Caching
In-memory caches in your application (Node.js: node-cache, Python: cachetools)
Best Practices
- Start with TTL-based caching - Simple and effective
- Monitor cache hit rates - Know if caching is working
- Use appropriate data structures - Hashes for objects, sets for collections
- Implement circuit breakers - Graceful degradation if cache fails
- Encrypt sensitive data - Never cache plaintext passwords/tokens
- Use consistent hashing - For distributed caches
- Compress large values - Save memory and network bandwidth
Conclusion
Caching is a powerful optimization technique, but it adds complexity. Choose the right strategy based on your:
- Read/write ratio
- Consistency requirements
- Data access patterns
- Infrastructure constraints
Start simple, measure impact, and iterate. A basic cache-aside strategy with proper TTLs can give you 90% of the benefits with 10% of the complexity.
Happy caching! 🚀