Back to Blog
cachingRedisCaffeinecache invalidationperformancedistributed systems

Caching Strategies in Java: Redis vs Caffeine vs Database Query Optimization

Master caching patterns. When to cache, what to cache, how to invalidate. Real-world trade-offs: memory vs latency, consistency vs performance.

J

JOptimize Team

October 1, 2026· 15 min read

The Caching Paradox: You're Not Fast Without It, You're Wrong With It

Scenario: Database query takes 500ms. With cache:

  • Hit: 5ms response (100x faster)
  • Miss (fresh load): 500ms (normal)
  • Miss (stale data returned): 5ms but wrong answer (disaster)

Caching is simple in theory. Brutal in practice.


Layer 1: Query Result Cache (In-Memory)

Caffeine Cache (JVM-Local)

@Configuration public class CacheConfig { @Bean public CacheManager cacheManager() { CaffeineCacheManager cacheManager = new CaffeineCacheManager("users", "products"); cacheManager.setCaffeine(Caffeine.newBuilder() .maximumSize(10000) // Max 10k entries .expireAfterWrite(10, TimeUnit.MINUTES) // 10 min TTL .recordStats()); // Track hit/miss rates return cacheManager; } } @Service public class UserService { @Cacheable("users") // Automatic caching by ID public User findById(Long id) { return userRepository.findById(id); // Hit = 5ms, Miss = 500ms } }

Trade-offs

Pros:

  • ✅ Fastest (in-process)
  • ✅ No network latency
  • ✅ Simple to implement

Cons:

  • ❌ Memory per instance (10k entries × 5KB = 50MB)
  • ❌ Not shared (each pod has own cache)
  • ❌ Invalidation per pod (complex)

When to Use

  • Small datasets (< 1GB total)
  • Local user state
  • Session data
  • Reference data (never changes)

JOptimize detects this automatically

Scan your Java project in 30 seconds — free, no credit card.

Try free →

Layer 2: Distributed Cache (Redis)

Redis Cache (Shared)

@Configuration public class CacheConfig { @Bean public CacheManager cacheManager(LettuceConnectionFactory connectionFactory) { return new RedisCacheManager.create( RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(10)) .serializeKeysWith( RedisSerializationContext.SerializationPair.fromSerializer( new StringRedisSerializer())) .serializeValuesWith( RedisSerializationContext.SerializationPair.fromSerializer( new GenericJackson2JsonRedisSerializer())), connectionFactory); } } @Service public class UserService { @Cacheable("users", cacheManager = "redisCache") public User findById(Long id) { return userRepository.findById(id); } }

Trade-offs

Pros:

  • ✅ Shared (all pods same cache)
  • ✅ Central invalidation
  • ✅ Larger datasets (can scale to 100GB+)

Cons:

  • ❌ Network latency (10ms vs 5ms)
  • ❌ Redis cluster complexity
  • ❌ Data serialization overhead

When to Use

  • Shared data across multiple pods
  • Datasets > 1GB
  • High-traffic endpoints
  • Team collaboration data

Layer 3: Database Query Optimization

Smart Queries Beat Cache

// ❌ BAD: Query everything, cache it @Cacheable("users") public List<User> getAllUsers() { return userRepository.findAll(); // Loads 1M rows! } // ✅ GOOD: Optimize query first @Cacheable("active-users") public List<User> getActiveUsers() { return userRepository.findByStatusAndCreatedAfter( "ACTIVE", LocalDateTime.now().minusDays(30)); // Only 10k rows }

Principle: Optimize the query first, then cache the result.


Cache Invalidation Strategies

Strategy 1: TTL (Time-To-Live)

cache: user: ttl: 10 minutes # Auto-expire after 10 min product: ttl: 1 hour # Slower-changing data config: ttl: 24 hours # Nearly static

Pros: Simple, no coordination needed Cons: Stale data possible for up to TTL duration

Strategy 2: Event-Based Invalidation

@Service public class UserService { @CacheEvict("users", key = "#user.id") @Transactional public User updateUser(User user) { return userRepository.save(user); } } // Or publish event @EventListener public void onUserUpdated(UserUpdatedEvent event) { redisTemplate.delete("user:" + event.userId); }

Pros: Fresh data immediately Cons: Requires event infrastructure, can miss updates

Strategy 3: Write-Through

public User updateUser(User user) { // 1. Update database userRepository.save(user); // 2. Update cache immediately cache.put("user:" + user.id, user); return user; }

Pros: Guaranteed fresh Cons: Slower writes, write fails if cache down

Strategy 4: Write-Behind (Async)

public User updateUser(User user) { // 1. Update cache immediately cache.put("user:" + user.id, user); // 2. Queue database update (async) asyncQueue.publish(new UpdateUserEvent(user)); return user; // Instant response }

Pros: Fastest writes Cons: Risk of data loss if queue crashes


Anti-Patterns: How Caching Fails

Anti-Pattern 1: Cache Stampede

// ❌ Problem: 1000 requests, key expires // All 1000 hit database simultaneously // Database overloaded // ✅ Solution: Probabilistic early expiration if (cache.getIfPresent(key) == null && Math.random() < 0.1) { // 10% preload cache.put(key, expensiveCompute()); // Refresh before expiry }

Anti-Pattern 2: Cache Poisoning

// ❌ Problem: Cache data from failed request Object data = externalAPI.fetch(); // Returns garbage on error cache.put(key, data); // Cached garbage for 10 min // ✅ Solution: Validate before caching Object data = externalAPI.fetch(); if (isValid(data)) { cache.put(key, data); } else { throw new InvalidDataException(); }

Anti-Pattern 3: Cascading Misses

// ❌ Problem: All data expires at once // Midnight: 1M cache entries expire // Same second: 1M database queries // Database down // ✅ Solution: Stagger expiration expireAfterWrite(10 + random(0, 5), TimeUnit.MINUTES) // 10-15 min

Metrics That Matter

Cache Hit Rate = Hits / (Hits + Misses)

Good: > 80%
Okay: 60-80%
Bad: < 60%

Monitor These

Caffeine cache with metrics: .recordStats() // Enables hit/miss tracking meterRegistry.gauge("cache.hits", cache::hitCount) meterRegistry.gauge("cache.misses", cache::missCount) meterRegistry.gauge("cache.size", cache::size)

Real Numbers: Caching Impact

Without cache:

Database query: 500ms
P99 latency: 2100ms (including network, parsing)
QPS: 100
Cost: Normal

With Redis cache:

Cache hit (80%): 20ms (80% of requests)
Cache miss (20%): 500ms (20% of requests)
Avg P99: 300ms (15x faster!)
QPS: 500 (5x more throughput)
Cost: +$200/month (Redis)
ROI: 100x (from 5x QPS gain alone)

Caching Decision Tree

Is data accessed repeatedly?
├─ No → Don't cache
├─ Yes → Is data larger than 1GB?
│   ├─ No → Use Caffeine (in-process)
│   ├─ Yes → Use Redis (distributed)
│   
├─ Data changes frequently?
│   ├─ Every second → Don't cache (or short TTL)
│   ├─ Every hour → Event-based invalidation
│   ├─ Daily → TTL cache (24h)
│   ├─ Never → TTL cache (infinity)

Summary

Caching is about trade-offs:

  • Speed: Cache adds speed
  • Consistency: Cache risks stale data
  • Complexity: Cache adds operational burden

Master these patterns, and you'll know when to cache and how to cache safely.

Get started: joptimize.io

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.