Dung (Donny) Nguyen

Senior Software Engineer

Caching in Spring Boot

Every request that hits the database, calls a remote service, or runs an expensive computation costs time and resources. When the same work produces the same answer over and over, repeating it is pure waste. Caching stores the result of that work so the next request can be served from fast memory instead of a slow round trip — dramatically improving latency and throughput while taking load off your database and downstream services.


Why Caching Matters

The trade-off is staleness: cached data can become out of date. Good caching is mostly about choosing what to cache and when to invalidate it.


Spring’s Cache Abstraction

Spring provides a caching abstraction that decouples your code from any specific cache provider. You annotate methods with caching behavior, and Spring wraps them with a proxy that checks the cache before invoking the method.

Enable Caching

@Configuration
@EnableCaching
public class CacheConfig {
}

@EnableCaching activates the annotation-driven cache behavior. Without it, the caching annotations are ignored.

@Cacheable

Caches the result of a method. On the first call, the method runs and the result is stored; subsequent calls with the same key return the cached value without executing the method body.

@Service
public class ProductService {

    private final ProductRepository productRepository;

    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Cacheable(value = "products", key = "#id")
    public Product findById(Long id) {
        // Only runs on a cache miss
        return productRepository.findById(id)
                .orElseThrow(() -> new ProductNotFoundException(id));
    }
}

@CachePut

Always executes the method and updates the cache with the result. Use it for update operations so the cache stays consistent with the database.

@CachePut(value = "products", key = "#product.id")
public Product update(Product product) {
    return productRepository.save(product);
}

@CacheEvict

Removes entries from the cache — essential for keeping data fresh after deletes or changes.

@CacheEvict(value = "products", key = "#id")
public void delete(Long id) {
    productRepository.deleteById(id);
}

@CacheEvict(value = "products", allEntries = true)
public void clearAll() {
    // Wipes the entire "products" cache
}

@Caching

Combines multiple cache operations on a single method when one annotation isn’t enough.

@Caching(evict = {
    @CacheEvict(value = "products", key = "#product.id"),
    @CacheEvict(value = "productsByCategory", key = "#product.category")
})
public Product save(Product product) {
    return productRepository.save(product);
}

Conditional Caching

You can control caching with condition (evaluated before the method runs) and unless (evaluated after, on the result).

@Cacheable(
    value = "products",
    key = "#id",
    unless = "#result == null",
    condition = "#id > 0")
public Product findById(Long id) {
    return productRepository.findById(id).orElse(null);
}

Choosing a Cache Provider

Spring’s abstraction works with many backends. You pick one by adding the right dependency and configuration.

Provider Best For Notes
ConcurrentHashMap (default) Local dev, tests Simple, no eviction policy, not production-grade
Caffeine Single-instance apps High-performance in-memory cache with size/time eviction
Redis Distributed / multi-instance apps Shared cache across nodes, survives restarts, supports TTL
Hazelcast / Ehcache Clustered caching In-memory data grids with more features

Caffeine (In-Memory)

<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>
spring:
  cache:
    type: caffeine
    caffeine:
      spec: maximumSize=1000,expireAfterWrite=10m

Caffeine gives you eviction by size and time — critical for bounding memory usage so a local cache doesn’t grow forever.

Redis (Distributed)

For multiple application instances, a local cache means each node has its own copy that can drift out of sync. Redis provides a shared, centralized cache that every instance reads from.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
spring:
  cache:
    type: redis
  data:
    redis:
      host: localhost
      port: 6379

Configure a default TTL so entries expire automatically:

@Bean
public RedisCacheConfiguration cacheConfiguration() {
    return RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .disableCachingNullValues();
}

Serialization tip: objects stored in Redis must be serializable. Configure a JSON serializer (e.g. GenericJackson2JsonRedisSerializer) for readable, interoperable cache entries instead of the default JDK serialization.


Cache Invalidation Strategies

The hardest part of caching is deciding when to remove stale data. Common strategies:

A practical default: use TTL as a safety net and evict on writes you control.


Common Pitfalls


When to Use Caching

Caching pays off most for data that is:

Reference data, configuration, product catalogs, and computed lookups are ideal candidates. Highly volatile, per-request, or write-heavy data usually is not.


Conclusion

Spring Boot’s cache abstraction lets you add caching with a few annotations while staying independent of the underlying provider. Start with @EnableCaching, apply @Cacheable, @CachePut, and @CacheEvict where they make sense, pick a provider that matches your topology (Caffeine for a single instance, Redis for a distributed one), and always plan your invalidation strategy up front. Done well, caching is one of the highest-impact, lowest-effort performance improvements you can make.