What is Spring Cache?

Spring Cache is the caching abstraction built into the Spring Framework. It is not a cache itself. It is a set of annotations and a CacheManager interface that let you mark a method as cacheable and leave the question of where the entries actually live to a provider you configure separately — an in-heap map, a data grid, or a shared Redis or Valkey server.

That separation is the whole point, and it is also where most of the confusion comes from. The annotations behave identically no matter what sits behind them, so the same code can run against a local ConcurrentHashMap in a test and a distributed cache in production. But anything the abstraction does not model — time to live, eviction policy, size limits, statistics — is the provider's business, and is configured in provider-specific ways.

How Spring Caching Works

Caching has been part of the framework since Spring 3.1, released in 2012. It is switched on with the @EnableCaching annotation, which tells Spring to create proxies around beans whose methods carry cache annotations.

Because @Cacheable checks the cache first and only invokes the method on a miss, Spring's caching abstraction is a read-through cache: the annotated method acts as the loader. Nothing is written to the cache except as a side effect of calling the method.

The Annotations

  • @Cacheable — check the cache first. On a hit, return the stored value and skip the method entirely. On a miss, run the method and store what it returns.
  • @CachePut — always run the method and store the result. Use it to refresh an entry after an update, where @Cacheable would have short-circuited the write.
  • @CacheEvict — remove an entry, or the whole cache with allEntries = true. See the note on eviction cost below before reaching for allEntries.
  • @CacheConfig — a class-level annotation that shares settings such as cache name and key generator across every method in the class, so you write them once.
  • @Caching — groups multiple annotations of the same or different types on one method, for cases where a single write needs to evict two caches.

How Keys Are Built

With no key attribute, Spring derives the key from the method's parameters using SimpleKeyGenerator: no arguments produce a shared empty key, one argument uses that argument itself, and several produce a composite SimpleKey. This means the parameter types must have sane equals and hashCode implementations, and it means an entity passed whole becomes part of the key. Naming the field you actually want with a SpEL expression — @Cacheable(value = "books", key = "#book.isbn") — is almost always the better choice.

Which Cache Provider Spring Boot Picks

If you have not defined a CacheManager bean yourself, Spring Boot detects a provider from the classpath, in a fixed order that includes JCache, Hazelcast, Infinispan, Couchbase, Redis, Caffeine and Cache2k. You can force one with spring.cache.type.

If none of them are present, caching still works — badly. Spring Boot falls back to a simple ConcurrentMapCacheManager backed by a ConcurrentHashMap. It has no size limit, no expiry and no eviction, so entries accumulate until the heap runs out. It is fine for a test and a liability in production. A cache that appears to work but was never configured is one of the more common ways this abstraction bites.

The provider you pick decides which features the abstraction can reach:

ProviderWhere entries liveShared across instancesTypical use
Simple (default fallback)On-heap ConcurrentHashMapNoTests only — no eviction at all
CaffeineOn-heap, in each JVMNoNanosecond reads of read-mostly reference data
HazelcastEmbedded or client/server gridYesExisting data grid deployments
Redis / ValkeyA server you run separatelyYesThe common distributed choice

The distinction that matters most is the third column. A local cache such as Caffeine is fast and simple, but every replica holds its own copy: a write on one instance leaves the others serving stale data until their own entries expire, and @CacheEvict evicts in one JVM only. Scaling from one pod to two silently changes correctness. A distributed cache moves the entries to a store every instance shares, which is why most teams that outgrow a single instance end up on Redis or Valkey. Putting a near cache in front of that shared store gives you both, provided there is an invalidation channel underneath it.

Spring Cache and TTL

There is no TTL in the Spring Cache abstraction. No annotation attribute sets an expiry, and there is no portable property that does it either. This is the single most common surprise for teams adopting the abstraction, and it follows directly from the design: expiry is a property of the cache, and Spring does not own the cache.

So TTL is always configured on the provider, in that provider's own vocabulary:

  • Redis — spring.cache.redis.time-to-live sets one default for every cache; entries never expire if you leave it unset. Per-cache values need a RedisCacheManagerBuilderCustomizer bean calling withCacheConfiguration(name, RedisCacheConfiguration.defaultCacheConfig().entryTtl(...)).
  • Caffeine — spring.cache.caffeine.spec carries expireAfterWrite or expireAfterAccess, and applies to every cache the manager builds.
  • Hazelcast — TTL is a property of the underlying IMap configuration, and per-entry TTL is reachable only through the IMap API, not through @Cacheable.
  • Redisson — a CacheConfig(ttl, maxIdleTime) per cache name, both in milliseconds; 0 or undefined means the entry lives indefinitely.

maxIdleTime above is a second, distinct policy worth knowing by name: time to idle resets on reads as well as writes, where plain TTL resets only on writes. An entry under a pure TTL policy expires on schedule no matter how often it was read.

Problems Teams Actually Hit

Self-invocation silently does nothing. The annotations work through proxies, so a call from one method of a bean to another method of the same bean does not pass through the proxy and is never cached. The fix is to move the cached method to a different bean, not to add more annotations.

Every concurrent miss runs the method. Ten requests arriving for a cold key all execute the loader. @Cacheable(sync = true) makes the rest wait for the first, which is the abstraction's answer to the thundering herd problem — though not every provider implements it.

@CacheEvict(allEntries = true) is not free. It calls Cache.clear(), and on Redis the default cache writer implements that with KEYS followed by DEL — an O(N) scan of the keyspace that blocks the server. On a large keyspace this is a visible latency event. Spring Data Redis offers a SCAN-based batch strategy as the documented alternative.

Null results are cached by default on some providers and forbidden on others. Whether a null return value occupies a cache entry — and therefore whether a missing row is remembered as missing — differs by provider and is configurable. It is worth deciding deliberately rather than discovering it.

Serialization is the provider's problem, and it will surface. Anything that leaves the JVM has to be encoded. On a distributed provider that means choosing a serializer, and a default that appears to work for simple types often fails on the first entity carrying a LocalDateTime. See serialization and our guide to codecs for the options.

Spring Caching in Redis

Redis and Valkey are the usual destination once a cache has to be shared. Reaching them from Spring needs a Java client: the default path is Spring Data Redis over Lettuce, and Redisson is the alternative that exposes a Spring CacheManager of its own along with distributed locks, collections and a near cache built on the same connection.

With Redisson, caches are declared by name with their eviction parameters, and RedissonSpringCacheManager is wired as the CacheManager bean:

@Configuration
@ComponentScan
@EnableCaching
public class Application {

    @Bean(destroyMethod = "shutdown")
    RedissonClient redisson() {
        Config config = new Config();
        config.useSingleServer()
              .setAddress("redis://127.0.0.1:6379");
        return Redisson.create(config);
    }

    @Bean
    CacheManager cacheManager(RedissonClient redissonClient) {
        Map<String, CacheConfig> config = new HashMap<>();

        // "testMap" spring cache: ttl = 24 minutes, maxIdleTime = 12 minutes
        config.put("testMap", new CacheConfig(24 * 60 * 1000, 12 * 60 * 1000));
        return new RedissonSpringCacheManager(redissonClient, config);
    }

}

For the full walkthrough — starter dependencies, @Cacheable wiring, TTL policies, serialization and adding a near cache for read-heavy workloads — see Spring Boot caching with Valkey or Redis. For the layer beneath the cache — RedisTemplate, repositories, and the Lettuce or Jedis driver the CacheManager runs on — see Redis with Spring Boot.

Running Spring Boot 4? The Redis auto-configuration moved to a new package and the Redisson starter now bundles a different Spring Data module by default — see Redisson on Spring Boot 4 for the version matrix and the upgrade traps.

Frequently Asked Questions

Which Cache Is Best for Spring Boot?

It depends on whether the cache has to be shared. On a single instance with read-mostly reference data, Caffeine is hard to beat — reads never leave the heap. The moment you run more than one replica, a local cache means each one holds its own copy and they disagree after any write, so a distributed provider such as Redis or Valkey becomes the correct answer. For read-heavy services, Caffeine in front of Redis with an invalidation channel between them beats either alone.

Does Spring Cache Support TTL?

Not in the abstraction. No Spring annotation sets an expiry. TTL is configured on the cache provider — spring.cache.redis.time-to-live for Redis, spring.cache.caffeine.spec for Caffeine, CacheConfig per cache name for Redisson. If you need per-entry rather than per-cache expiry, check that your provider supports it, because several do not.

What Is the Difference Between @Cacheable and @CachePut?

@Cacheable skips the method when the key is already cached. @CachePut always runs the method and then stores the result. Use @Cacheable on reads and @CachePut on updates, where you want the cache refreshed rather than bypassed.

Why Is My @Cacheable Method Not Being Cached?

The usual cause is self-invocation: the annotations work through proxies, so calling the annotated method from another method of the same class bypasses the proxy entirely and no caching happens. Other common causes are a missing @EnableCaching, a non-public method, or a key generated from an object without proper equals and hashCode.

Is Spring Cache Thread-Safe?

The abstraction is, but concurrent misses on a cold key all execute the loader by default. @Cacheable(sync = true) makes concurrent callers wait for the first one instead — the behaviour most people assume they already have.

Similar terms