Caching: the Spring Cache Abstraction, Caffeine, and Redis

This section documents Spring Boot 4.1.x and Spring Framework 7.0.x on the Java 17/21+ baseline, as described by the official Spring Boot reference documentation and each sub-project’s own site (Spring Data, Spring for Apache Kafka, Micrometer, Project Reactor, springdoc-openapi, Spring gRPC, and the others listed in this section’s Bibliography) — which are the references these pages are written and verified against.

This content was generated with the assistance of AI and should be verified against those official docs before being relied on in production. Spring Boot and its ecosystem continue to evolve; the examples here target the current 4.1.x / 7.0.x releases.

This section’s bibliography lists the reference material consulted while preparing these pages.

Spring’s cache abstraction lets a method’s return value be transparently stored and reused on subsequent calls with the same arguments, without the method body ever mentioning a cache. This page covers the annotation-driven abstraction itself, a local in-memory provider (the default ConcurrentMapCacheManager and the production-grade Caffeine), and a distributed provider (Redis), including how their configuration and consistency trade-offs differ.

The Spring Cache abstraction

Like @Transactional (see Core Concepts), caching is implemented as a proxy around the annotated bean: a call from outside the proxy is intercepted, but a self-invocation (this.someCacheableMethod()) bypasses it and silently skips the cache. Enable the abstraction on a configuration class:

@Configuration
@EnableCaching
public class CacheConfig {
    // cache manager beans go here (see below)
}

@Cacheable

@Cacheable caches a method’s return value, keyed by its arguments. On a hit, the method body never runs:

@Service
public class ProductService {

    private final ProductRepository productRepository;

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

    @Cacheable(cacheNames = "products", key = "#id")
    public Product findById(Long id) {
        // only executes on a cache miss; the result is then stored under key `id`
        return productRepository.findById(id)
                .orElseThrow(() -> new ProductNotFoundException(id));
    }
}

cacheNames (or its alias value) names one or more caches; key is a SpEL expression evaluated against the method’s arguments, exposed as #argName (or #p0/#a0 positionally). When key is omitted, a KeyGenerator derives it from all arguments — fine for a single-argument method, ambiguous for several.

@CachePut

@CachePut always runs the method and updates the cache with its result, instead of skipping the call on a hit. Use it for writes that must also refresh what read paths will see next:

@CachePut(cacheNames = "products", key = "#product.id")
public Product update(Product product) {
    // always executes; the returned value replaces the cached entry for this id
    return productRepository.save(product);
}

@CacheEvict

@CacheEvict removes one entry (or, with allEntries = true, the whole cache) so a future call falls through to the method again:

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

@CacheEvict(cacheNames = "products", allEntries = true)
public void clearCatalog() {
    // wipes every entry in the "products" cache, e.g. after a bulk import
}

beforeInvocation = true evicts before the method runs rather than after it returns successfully, which matters if the method may throw.

Conditional caching with SpEL: condition and unless

condition decides, before invocation, whether caching applies at all; unless decides, after invocation, whether the result is vetoed from being stored — both are SpEL expressions, and unless can inspect the return value as #result:

@Cacheable(
        cacheNames = "products",
        key = "#id",
        condition = "#id > 0",              // skip caching entirely for non-positive ids
        unless = "#result != null && #result.discontinued"   // don't cache discontinued products
)
public Product findById(Long id) {
    return productRepository.findById(id)
            .orElseThrow(() -> new ProductNotFoundException(id));
}

A SpEL expression like condition = "#result != null" is only valid syntax inside a [source,java] annotation attribute as shown above; the same characters typed as prose need escaping (see the note under Redis configuration below). Several caches can also be targeted together with @Caching, which groups multiple @Cacheable/@CachePut/@CacheEvict declarations on one method. See the Spring Boot caching reference for the full annotation model, @CacheConfig for class-level defaults, and custom KeyGenerator/CacheResolver beans.

Local caching: ConcurrentMapCacheManager and Caffeine

Adding spring-boot-starter-cache and calling @EnableCaching is enough to get caching working out of the box: with no other cache library on the classpath, Spring Boot auto-configures a ConcurrentMapCacheManager, backed by a plain java.util.concurrent.ConcurrentHashMap per cache name.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

ConcurrentMapCacheManager is simple and dependency-free, but it has no eviction policy, no size limit, and no built-in expiry — every entry lives forever until explicitly evicted or the JVM restarts. It is adequate for tests, demos, or caches with a naturally small, bounded key space; anything else risks an unbounded memory leak.

Caffeine

Caffeine is a high-performance local cache with size-based eviction (window TinyLFU), time-based expiry, and statistics. Add it alongside the starter and Spring Boot’s auto-configuration switches from ConcurrentMapCacheManager to CaffeineCacheManager automatically:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

Size and expiry are configured either declaratively, via a Caffeine spec string in application.yml:

spring:
  cache:
    cache-names: products, inventory
    caffeine:
      spec: maximumSize=10000,expireAfterWrite=10m,recordStats

or programmatically, for per-cache tuning that a single shared spec can’t express:

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager("products", "inventory");
        cacheManager.setCaffeine(Caffeine.newBuilder()
                .maximumSize(10_000)
                .expireAfterWrite(Duration.ofMinutes(10))
                .recordStats());
        return cacheManager;
    }
}

maximumSize bounds memory use by evicting the least-valuable entries once the limit is reached; expireAfterWrite bounds staleness by evicting an entry a fixed duration after it was written, regardless of how often it’s read (expireAfterAccess instead resets the timer on every read); recordStats turns on hit/miss counters retrievable via CaffeineCache#getNativeCache().stats(), or exported as Micrometer metrics when spring-boot-starter-actuator is present. See the Spring Boot caching reference (Caffeine section) for the full spec-string syntax and the provider-selection order Spring Boot follows when several cache libraries are on the classpath.

Distributed caching with Redis

A local cache like Caffeine is private to one JVM: in a multi-instance deployment each instance builds up its own copy, and an eviction on one instance leaves the others stale. Redis backs a shared, network-accessible cache that every instance reads and writes consistently.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

With both starters present and a reachable Redis server, Spring Boot auto-configures a RedisCacheManager backed by Spring Data Redis's RedisCache, on top of the same @Cacheable/@CachePut/@CacheEvict annotations already shown above — no code change is needed to move from Caffeine to Redis, only configuration.

spring:
  data:
    redis:
      host: localhost
      port: 6379
  cache:
    type: redis
    redis:
      time-to-live: 10m
      cache-null-values: false
      key-prefix: "app::"
      use-key-prefix: true

time-to-live sets the TTL applied to every entry written through the cache abstraction — unlike Caffeine’s in-JVM expiry, this TTL is enforced by the Redis server itself, so it survives an application restart. cache-null-values: false avoids storing a Redis entry (and its own TTL bookkeeping) for methods whose result was null; key-prefix/use-key-prefix namespace keys so multiple applications can share one Redis instance without colliding.

Serialization

Values placed in a RedisCache must be serialized to bytes; the default is JDK serialization, which requires Serializable and produces a compact but non-human-readable, Java-only format. A RedisCacheManager can be built explicitly to use JSON instead, which is portable across languages and easier to inspect with redis-cli:

@Configuration
@EnableCaching
public class RedisCacheConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10))
                .disableCachingNullValues()
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()));

        Map<String, RedisCacheConfiguration> perCacheConfigs = Map.of(
                "inventory", defaultConfig.entryTtl(Duration.ofSeconds(30)) // faster-changing data, shorter TTL
        );

        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(defaultConfig)
                .withInitialCacheConfigurations(perCacheConfigs)
                .build();
    }
}

RedisCacheConfiguration is immutable, so withInitialCacheConfigurations overrides the default per named cache — here inventory gets a much shorter TTL than every other cache, reflecting how quickly stock levels go stale. See Redis’s own guide to the Spring Framework cache integration for additional serializer options and connection-pool tuning with Lettuce/Jedis.

Cache-aside vs. read/write-through

The annotation-driven abstraction shown throughout this page implements cache-aside (also called lazy-loading): the application code (via @Cacheable) is responsible for checking the cache first and, on a miss, loading from the source of truth and populating the cache itself. This is what @Cacheable, @CachePut, and @CacheEvict give you directly, and it is the right default for most Spring Boot applications.

Read-through and write-through push that responsibility into the caching layer itself: a read-through cache loads misses from the underlying store on its own (via a configured loader), and a write-through cache writes to the underlying store synchronously as part of every cache write, so the cache and the store are never observably out of sync. Spring’s cache abstraction does not implement read/write-through out of the box — achieving it means writing a CacheLoader-style wrapper around the repository call, or reaching for a library that supports it natively. In practice cache-aside with a short, deliberately chosen time-to-live (as configured above) is simpler to reason about and is what most Spring Boot + Redis deployments use; consider read/write-through only when strict cache/store consistency matters more than that simplicity.

Choosing between Caffeine and Redis

  • Caffeine (local, in-JVM) — lowest latency (no network hop), no extra infrastructure to run, but not shared across instances and lost on restart. Best for read-heavy, per-instance data where brief inconsistency between instances is acceptable.

  • Redis (distributed) — shared and consistent across every application instance, survives an individual instance restart, and can be sized independently of application memory, at the cost of network latency and an operated Redis deployment.

The two are not mutually exclusive: layering a small Caffeine cache in front of a larger Redis cache (a "near-far" cache) absorbs the hottest keys locally while still sharing the bulk of the data through Redis. See Near-Far Caches for the pattern’s trade-offs and Spring Boot implementation options. For the full picture of provider auto-detection, spring.cache.type override values, and every supported provider (JCache, Hazelcast, Infinispan, Couchbase, and others beyond the two covered here), see the Spring Boot caching reference.