Caching Strategies — Redis, In-Memory & HTTP Cache Headers
A practical guide to caching — when to cache, where to cache, cache invalidation strategies, Redis patterns, HTTP cache headers, and avoiding stale data.
“There are only two hard things in Computer Science: cache invalidation and naming things.” — Phil Karlton
Caching is the fastest way to improve performance. A database query that takes 50ms becomes a cache hit at 1ms. An API call to a third-party service that takes 200ms becomes near-instant. But caching wrong gives you stale data, inconsistencies, and debugging nightmares.
This post covers where to cache, how to invalidate, and which strategy to use.
Why cache?
Every time your app serves a request, it might:
- Query a database (10–100ms)
- Call an external API (50–500ms)
- Compute something expensive (10–1000ms)
If the same result is requested frequently and doesn’t change often, compute it once and serve from cache.
Without cache: Client → App → Database → App → Client (50ms)
With cache: Client → App → Cache hit → Client (1ms)
Cache miss: Client → App → Cache miss → Database → App → (store in cache) → Client (55ms)
Where to cache
1. In-memory cache (application level)
Store data in the application’s memory. Fastest possible access.
import com.github.benmanes.caffeine.cache.Caffeine
import java.time.Duration
val userCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5))
.build<String, User>()
fun getUser(id: String): User {
return userCache.get(id) { userId ->
userRepository.findById(userId) // only called on cache miss
}
}
Pros: Fastest (nanosecond access), no network hop, simple. Cons: Per-instance (each server has its own cache), limited by JVM heap, lost on restart.
Use for: Configuration data, reference data, computed values that don’t change per request.
2. Distributed cache (Redis, Memcached)
A separate cache server shared by all application instances.
@Service
class UserService(
private val redisTemplate: RedisTemplate<String, User>,
private val userRepository: UserRepository
) {
fun getUser(id: String): User {
val cacheKey = "user:$id"
// Try cache first
val cached = redisTemplate.opsForValue().get(cacheKey)
if (cached != null) return cached
// Cache miss — load from DB
val user = userRepository.findById(id)
?: throw UserNotFoundException(id)
// Store in cache with TTL
redisTemplate.opsForValue().set(cacheKey, user, Duration.ofMinutes(10))
return user
}
fun updateUser(id: String, update: UpdateUserRequest): User {
val user = userRepository.save(/* ... */)
// Invalidate cache
redisTemplate.delete("user:$id")
return user
}
}
Pros: Shared across instances, survives app restarts, large capacity. Cons: Network hop (1–5ms), needs infrastructure, serialization overhead.
Use for: Session data, frequently accessed entities, API response caching, rate limiting counters.
3. HTTP caching (browser/CDN)
Let the client or CDN cache responses. Zero load on your server for cached requests.
@GetMapping("/api/products/{id}")
fun getProduct(@PathVariable id: String): ResponseEntity<Product> {
val product = productService.getProduct(id)
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(Duration.ofHours(1)))
.eTag(product.version.toString())
.body(product)
}
Pros: Reduces server load to zero for cached requests, CDN can serve globally. Cons: Hard to invalidate, client might have stale data.
Use for: Static assets, public API responses, product catalogs, blog content.
Cache invalidation strategies
Time-based expiration (TTL)
The simplest approach. Data expires after a fixed time:
redisTemplate.opsForValue().set("user:123", user, Duration.ofMinutes(10))
After 10 minutes, the cache entry disappears. Next request hits the database.
Pros: Simple, predictable, self-healing. Cons: Data is stale until TTL expires. Setting TTL is a tradeoff — too short defeats caching, too long serves stale data.
Write-through
Update cache immediately when data changes:
fun updateUser(id: String, update: UpdateUserRequest): User {
val user = userRepository.save(/* ... */)
redisTemplate.opsForValue().set("user:$id", user, Duration.ofMinutes(10))
return user
}
Cache is always fresh after writes. But what if the cache write fails? The database has the new data but cache has old data.
Cache-aside (lazy loading)
Don’t update cache on write. Delete the cache entry and let the next read repopulate it:
fun updateUser(id: String, update: UpdateUserRequest): User {
val user = userRepository.save(/* ... */)
redisTemplate.delete("user:$id") // invalidate, don't update
return user
}
fun getUser(id: String): User {
val cached = redisTemplate.opsForValue().get("user:$id")
if (cached != null) return cached
val user = userRepository.findById(id)
redisTemplate.opsForValue().set("user:$id", user, Duration.ofMinutes(10))
return user
}
This is the most common pattern. Invalidate on write, repopulate on read. Simple and reliable.
Event-based invalidation
Listen for change events and invalidate the cache:
@KafkaListener(topics = ["user-events"])
fun onUserUpdated(event: UserUpdated) {
redisTemplate.delete("user:${event.userId}")
}
Useful in microservices where the service that owns the data isn’t the same one that caches it.
Redis patterns
Cache with Spring Boot
@Configuration
@EnableCaching
class CacheConfig {
@Bean
fun cacheManager(connectionFactory: RedisConnectionFactory): RedisCacheManager {
val config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
GenericJackson2JsonRedisSerializer()
)
)
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.build()
}
}
@Service
class ProductService(private val repository: ProductRepository) {
@Cacheable("products", key = "#id")
fun getProduct(id: String): Product {
return repository.findById(id) ?: throw NotFoundException()
}
@CacheEvict("products", key = "#id")
fun updateProduct(id: String, update: UpdateProductRequest): Product {
return repository.save(/* ... */)
}
@CacheEvict("products", allEntries = true)
fun clearProductCache() {
// called when bulk data changes
}
}
@Cacheable checks cache before executing the method. @CacheEvict invalidates on write.
Rate limiting with Redis
fun isRateLimited(userId: String, limit: Int, windowSeconds: Long): Boolean {
val key = "rate:$userId"
val current = redisTemplate.opsForValue().increment(key) ?: 1
if (current == 1L) {
redisTemplate.expire(key, Duration.ofSeconds(windowSeconds))
}
return current > limit
}
// Usage
if (isRateLimited(userId, limit = 100, windowSeconds = 60)) {
throw TooManyRequestsException()
}
Session storage
// Store session in Redis
redisTemplate.opsForHash<String, Any>().putAll("session:$sessionId", mapOf(
"userId" to userId,
"role" to role,
"loginAt" to Instant.now().toString()
))
redisTemplate.expire("session:$sessionId", Duration.ofHours(24))
// Retrieve session
val session = redisTemplate.opsForHash<String, Any>().entries("session:$sessionId")
HTTP cache headers
Cache-Control
Cache-Control: max-age=3600 # cache for 1 hour
Cache-Control: no-cache # validate with server before using
Cache-Control: no-store # never cache (sensitive data)
Cache-Control: private, max-age=600 # only browser caches (not CDN)
Cache-Control: public, max-age=86400 # CDN and browser can cache
| Directive | Meaning |
|---|---|
max-age=N | Cache for N seconds |
no-cache | Can cache, but must revalidate every time |
no-store | Don’t cache at all |
private | Only client can cache (not CDN) |
public | Anyone can cache |
must-revalidate | Once expired, must revalidate (don’t serve stale) |
ETag for conditional requests
@GetMapping("/api/products/{id}")
fun getProduct(
@PathVariable id: String,
request: HttpServletRequest
): ResponseEntity<Product> {
val product = productService.getProduct(id)
val etag = "\"${product.version}\""
// Check if client has current version
val clientEtag = request.getHeader("If-None-Match")
if (clientEtag == etag) {
return ResponseEntity.status(304).build() // Not Modified
}
return ResponseEntity.ok()
.eTag(etag)
.cacheControl(CacheControl.maxAge(Duration.ofMinutes(5)))
.body(product)
}
The client sends If-None-Match: "v3". If the product is still version 3, the server returns 304 (empty body). The client uses its cached copy. Saves bandwidth and serialization.
What to cache (and what not to)
Good candidates
- Database query results that don’t change frequently
- External API responses
- Computed values (aggregations, recommendations)
- Static reference data (countries, categories, config)
- Session data
- Rate limiting counters
Bad candidates
- Real-time data (stock prices, live scores)
- User-specific data that changes every request
- Data that must be immediately consistent (account balances)
- Large objects that are rarely reused
- Security-sensitive data (tokens, passwords)
Common mistakes
1. Cache without invalidation
“We’ll just set a 24-hour TTL.” Then a product price changes and customers see the old price for 23 hours. Always have an invalidation strategy.
2. Cache stampede
Cache expires. 1000 requests hit simultaneously. All 1000 query the database. Use a lock or stale-while-revalidate:
fun getProductWithLock(id: String): Product {
val cached = cache.get(id)
if (cached != null) return cached
// Acquire lock — only one thread fetches from DB
val lock = redisTemplate.opsForValue().setIfAbsent("lock:product:$id", "1", Duration.ofSeconds(5))
if (lock == true) {
val product = repository.findById(id)
cache.set(id, product, Duration.ofMinutes(10))
redisTemplate.delete("lock:product:$id")
return product
}
// Wait briefly and retry cache
Thread.sleep(50)
return cache.get(id) ?: repository.findById(id)
}
3. Caching errors
A database is temporarily down. You cache the error response. Now even after the database recovers, you’re serving errors from cache. Don’t cache failures.
4. Not monitoring cache hit rate
A cache with a 10% hit rate is adding complexity without benefit. Monitor:
- Hit rate (should be > 80% for most caches)
- Miss rate and source (are misses because of TTL or invalidation?)
- Memory usage
- Eviction rate
Summary
| Layer | Tool | TTL | Use case |
|---|---|---|---|
| Application | Caffeine | Seconds–minutes | Hot data, config |
| Distributed | Redis | Minutes–hours | Shared state, sessions |
| HTTP | Cache-Control + ETag | Minutes–days | API responses, static content |
Cache strategy checklist:
- Identify what to cache (frequent reads, slow sources)
- Choose where to cache (in-memory, Redis, HTTP)
- Set appropriate TTL (balance freshness vs performance)
- Implement invalidation (cache-aside is the safest default)
- Monitor hit rates and adjust
Start with cache-aside + TTL. Add write-through or event-based invalidation when freshness requirements demand it.