Skip to content
Spring Boot sb production 4 min read

Caching

Caching stores the result of an expensive operation — a slow query, a remote API call, a heavy computation — so the next identical call returns instantly without redoing the work. Spring Boot provides a cache abstraction: you annotate methods declaratively, and Spring transparently checks the cache before invoking the method and stores the result afterward. The same annotations work whether the backing store is an in-memory map, Redis, Caffeine, or Hazelcast.

Enabling caching

Caching is off until you switch it on with @EnableCaching, typically on a configuration class or the main application class.

import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableCaching
public class CacheConfig {
}

With no cache provider on the classpath, Spring Boot configures a simple ConcurrentMapCacheManager that stores entries in a ConcurrentHashMap. That is fine for development and single-instance apps, but it never evicts by time and is not shared across instances — for production, switch to a real provider.

@Cacheable

@Cacheable is the workhorse: on the first call it runs the method and stores the result under a key; on later calls with the same key it returns the cached value and skips the method body entirely.

import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Service
public class ProductService {

    private final ProductRepository repository;

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

    @Cacheable("products")
    public Product findById(Long id) {
        simulateSlowQuery();
        return repository.findById(id)
                .orElseThrow(() -> new ProductNotFoundException(id));
    }

    private void simulateSlowQuery() {
        try { Thread.sleep(2000); } catch (InterruptedException ignored) { }
    }
}

Output (console log shows the method runs once):

Call 1: findById(42)  -> 2003 ms   (cache miss, query executed)
Call 2: findById(42)  ->    0 ms   (cache hit, method skipped)
Call 3: findById(99)  -> 2001 ms   (different key, cache miss)

By default the key is derived from the method arguments (here, id). The default KeyGenerator uses all parameters.

Keys and conditions with SpEL

You can override the key and add conditions with SpEL expressions.

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

// Compose a key from multiple arguments
@Cacheable(value = "search", key = "#category + '-' + #page")
public List<Product> search(String category, int page) { ... }
AttributeMeaning
keySpEL for the cache key (default: all args)
conditionCache only if this evaluates true (checked before the call)
unlessSkip caching if true (checked after, can use #result)
sync = trueLock so concurrent misses compute the value only once

Tip: Use unless = "#result == null" to avoid caching empty results, and sync = true to prevent a cache stampede where many threads recompute the same missing key simultaneously.

@CacheEvict — removing stale entries

When the underlying data changes, evict the cached copy so readers don’t see stale data.

import org.springframework.cache.annotation.CacheEvict;

@CacheEvict(value = "products", key = "#product.id")
public void update(Product product) {
    repository.save(product);
}

// Clear the whole cache (e.g. after a bulk import)
@CacheEvict(value = "products", allEntries = true)
public void reloadCatalog() {
    repository.refreshAll();
}

@CachePut — update without skipping

@CachePut always runs the method and stores the result, refreshing the cache. Use it for save/update methods where you want the new value cached, unlike @Cacheable, which would short-circuit.

import org.springframework.cache.annotation.CachePut;

@CachePut(value = "products", key = "#result.id")
public Product save(Product product) {
    return repository.save(product);   // always executes, result is cached
}
AnnotationRuns the method?Stores result?Typical use
@Cacheableonly on missyesreads (findById)
@CachePutalwaysyescreate/update
@CacheEvictalwaysremovesdelete/invalidate

Warning: Caching is AOP-proxy based, so it does not apply to calls a bean makes to its own methods (self-invocation). Calling this.findById(id) from inside the same class bypasses the cache, exactly like @Transactional and @Async. Call through an injected reference instead.

Configuring cache names and TTL

The default map cache has no expiry. Caffeine adds size and time eviction while staying in-process — a great upgrade for single-instance apps.

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

For a cache shared across multiple instances — the usual production need — use Redis instead. See Redis Caching.

Best Practices

  • Always pair every @Cacheable read with an eviction strategy (@CacheEvict/@CachePut) on writes.
  • Set a TTL with Caffeine or Redis; the default map cache never expires entries.
  • Use sync = true or unless to avoid stampedes and caching nulls.
  • Remember self-invocation bypasses the proxy — call cached methods from another bean.
  • Cache stable, read-heavy data; avoid caching fast-changing or user-specific data unless keyed carefully.
Last updated June 13, 2026
Was this helpful?