System Design

Caching Strategies: A Complete Guide to Performance Optimization

✍️ Taylson Martinez
15 min read
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

Read 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

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 TypeTTLReasoning
User sessions30 min - 2 hoursBalance security and UX
User profiles1 - 24 hoursChanges infrequently
Product catalog5 - 60 minutesUpdates periodically
Real-time data10 - 60 secondsNeeds to be fresh
Static content7 - 30 daysRarely 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

  1. Start with TTL-based caching - Simple and effective
  2. Monitor cache hit rates - Know if caching is working
  3. Use appropriate data structures - Hashes for objects, sets for collections
  4. Implement circuit breakers - Graceful degradation if cache fails
  5. Encrypt sensitive data - Never cache plaintext passwords/tokens
  6. Use consistent hashing - For distributed caches
  7. 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! 🚀