Save the Day (and Memory): Java Caching Strategies Using Caffeine and Redis
Caching is one of those things that sounds simple until you actually need it. Then suddenly you’re drowning in cache invalidation strategies, distributed system headaches, and wondering why your application is eating memory like it’s going out of style.
Let’s cut through the noise and look at two heavyweight champions of the Java caching world: Caffeine for local caching and Redis for distributed caching. We’ll cover when to use each, how to implement them properly, and how to avoid the pitfalls that’ll have you debugging at 2 AM.
Why Cache in the First Place?
Before we dive into implementation, let’s be clear about what we’re solving. You cache to:
- Reduce database load – Stop hammering your database with the same queries
- Improve response times – Serve data from memory instead of disk or network
- Handle traffic spikes – Keep your system responsive when things get wild
The trade-off? Memory usage and complexity. Choose your caching strategy wisely.
Caffeine: Your Local Cache Champion
Caffeine is a high-performance, in-memory cache for Java. It’s fast, lightweight, and sits right in your application’s heap. Think of it as your first line of defense.
When to Use Caffeine
Use Caffeine when:
- You need blazing-fast access (microseconds)
- Your data is read frequently and doesn’t change often
- You’re running a single instance or can tolerate cache inconsistency across instances
- Memory footprint per instance is manageable
Basic Implementation
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
public class UserCache {
private final Cache<Long, User> cache;
public UserCache() {
cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(10))
.recordStats()
.build();
}
public User getUser(Long userId) {
return cache.get(userId, id -> userRepository.findById(id));
}
}
This cache holds up to 10,000 users, expires entries after 10 minutes, and tracks statistics for monitoring.
Advanced Pattern: Loading Cache with Refresh
LoadingCache<String, ProductList> productCache = Caffeine.newBuilder()
.maximumSize(1_000)
.expireAfterWrite(Duration.ofMinutes(30))
.refreshAfterWrite(Duration.ofMinutes(25))
.build(key -> productService.fetchProducts(key));
// Usage
ProductList products = productCache.get("electronics");
The refresh mechanism reloads values asynchronously before they expire, preventing the thundering herd problem where many requests wait for cache repopulation.
Redis: Distributed Caching Done Right
Redis takes caching beyond a single machine. It’s your go-to when you need cache consistency across multiple instances or want to share session data across services.
When to Use Redis
Use Redis when:
- You’re running multiple application instances
- You need cache consistency across your infrastructure
- Session management needs to span multiple servers
- You want advanced data structures (sorted sets, lists, hashes)
Spring Boot + Redis Example
@Configuration
@EnableCaching
public class RedisConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(60))
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())
)
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())
)
.disableCachingNullValues();
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.build();
}
}
@Service
public class OrderService {
@Cacheable(value = "orders", key = "#orderId")
public Order getOrder(Long orderId) {
return orderRepository.findById(orderId);
}
@CacheEvict(value = "orders", key = "#order.id")
public Order updateOrder(Order order) {
return orderRepository.save(order);
}
}
The annotations make caching declarative, but be careful—they hide complexity that can bite you during debugging.
The Two-Tier Strategy: Best of Both Worlds
Why choose when you can have both? A two-tier cache combines Caffeine’s speed with Redis’s distribution.
@Service
public class ProductService {
private final LoadingCache<String, Product> l1Cache;
private final RedisTemplate<String, Product> redisTemplate;
public ProductService(RedisTemplate<String, Product> redisTemplate) {
this.redisTemplate = redisTemplate;
this.l1Cache = Caffeine.newBuilder()
.maximumSize(5_000)
.expireAfterWrite(Duration.ofMinutes(5))
.build(this::fetchFromL2OrDatabase);
}
public Product getProduct(String productId) {
return l1Cache.get(productId);
}
private Product fetchFromL2OrDatabase(String productId) {
// Try Redis first
Product product = redisTemplate.opsForValue().get("product:" + productId);
if (product != null) {
return product;
}
// Fall back to database
product = productRepository.findById(productId);
if (product != null) {
redisTemplate.opsForValue().set(
"product:" + productId,
product,
Duration.ofMinutes(30)
);
}
return product;
}
}
This pattern gives you microsecond local access with distributed consistency as a safety net.
Cache Invalidation: The Hard Part
Phil Karlton famously said there are only two hard things in computer science: cache invalidation and naming things. He wasn’t wrong.
Strategies That Actually Work
Time-based expiration: Simple but can serve stale data. Fine for analytics or non-critical reads.
Event-driven invalidation: Use Redis pub/sub or message queues to broadcast updates.
@Service
public class CacheInvalidationService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
public void invalidateProductCache(String productId) {
// Invalidate Redis
redisTemplate.delete("product:" + productId);
// Publish invalidation event for other instances
redisTemplate.convertAndSend("cache-invalidation", productId);
}
}
Write-through cache: Update cache and database atomically. More complex but maintains consistency.
Monitoring: You Can’t Fix What You Can’t See
Always track your cache performance:
CacheStats stats = cache.stats();
logger.info("Cache stats - Hits: {}, Misses: {}, Hit Rate: {}%",
stats.hitCount(),
stats.missCount(),
stats.hitRate() * 100);
Watch for:
- Hit rate dropping below 80% (might need larger cache or better eviction policy)
- High miss rate on startup (consider cache warming)
- Memory pressure causing evictions
Common Pitfalls to Avoid
- Over-caching: Not everything needs to be cached. If it’s accessed once per day, skip it.
- Under-sizing: A cache that constantly evicts hot data is worse than no cache.
- Ignoring serialization cost: Redis requires serialization. Large objects can kill performance.
- Forgetting about cache warming: Cold caches on deployment can crush your database.
- No monitoring: Flying blind leads to production surprises.
Useful Resources
Documentation & Guides
- Caffeine GitHub Wiki – Official docs with detailed examples
- Spring Cache Abstraction – Spring’s caching documentation
- Redis Documentation – Comprehensive Redis guides
Libraries & Tools
- Caffeine on Maven Central – Latest versions
- Spring Data Redis – Spring Redis integration
- Redisson – Advanced Redis client with distributed objects
Performance & Best Practices
- High Performance Caching with Caffeine – Practical patterns
- Redis Best Practices – Official optimization guide


