Spring Boot Redis Cache: A Complete Guide to @Cacheable

Last updated
September 10, 2026

Putting Redis behind @Cacheable in Spring Boot takes two dependencies and one annotation. The cache works immediately, which is the problem: the defaults you get are almost certainly not the ones you want. Entries never expire. Values are stored with Java serialization, so nothing in Redis is readable and every cached class has to implement Serializable. Statistics are off. And @CacheEvict(allEntries = true) quietly runs KEYS against your production keyspace.

This guide covers the configuration that turns that working-but-wrong default into something you can run. Everything here applies equally to Valkey, the open-source fork Redis compatibility is maintained against — where the text says Redis, read "Redis or Valkey" throughout.

Caching is one half of what the starter gives you. For RedisTemplate, Spring Data repositories, the driver underneath and what changed in Spring Data Redis 4, see our guide to Redis with Spring Boot.

What Spring Boot Auto-Configures When Redis Is on the Classpath

If you have not defined a CacheManager bean of your own, Spring Boot works down a fixed list of cache providers and takes the first one it finds on the classpath — JCache, Hazelcast, Infinispan, Couchbase, Redis, Caffeine, Cache2k, and finally a simple in-memory fallback. When Redis is configured and available, you get a RedisCacheManager.

You can stop relying on classpath ordering by naming the provider explicitly, which is worth doing in any application that has more than one caching library present:

spring:
  cache:
    type: redis

One warning before the configuration: if none of those providers is present, caching still appears to work. Spring Boot falls back to a ConcurrentMapCacheManager backed by a plain ConcurrentHashMap — no size limit, no expiry, no eviction, entries accumulating until the heap gives out. Our glossary entry on Spring Cache covers the abstraction and that fallback in more detail.

Cache defaults come from six properties. These are the entire surface, and their defaults matter more than their existence:

PropertyDefaultWhat it does
spring.cache.redis.time-to-livenoneEntry expiration. By default the entries never expire.
spring.cache.redis.key-prefixnoneA prefix placed before the cache name.
spring.cache.redis.use-key-prefixtrueWhether to prefix keys at all.
spring.cache.redis.cache-null-valuestrueWhether null returns occupy a cache entry.
spring.cache.redis.enable-statisticsfalseHit and miss counters.
spring.cache.cache-names—Caches to create at startup.

The first row is the one that catches people. There is no default TTL. A cache configured with nothing but spring.cache.type=redis will hold every entry it ever writes, forever, until something evicts it or Redis runs out of memory.

Adding the Dependencies

Two starters: one for the caching abstraction, one for Redis.

<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>

Then switch the abstraction on:

@SpringBootApplication
@EnableCaching
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

Worth knowing what you just pulled in: spring-boot-starter-data-redis ships Lettuce as its client. Spring Boot's own documentation is explicit that the starter offers auto-configuration for both Lettuce and Jedis and that "by default, it uses Lettuce." Switching is a property rather than a code change:

spring:
  data:
    redis:
      client-type: jedis   # or lettuce, the default
      host: localhost
      port: 6379

The client choice affects connection handling and threading, not caching semantics — RedisCacheManager is the same class either way. One operational caveat: master/replica is not supported by Jedis in Spring Boot's auto-configuration, so a read-replica setup is a reason to stay on Lettuce. Our comparison of Jedis and Lettuce covers the trade-off properly.

Using @Cacheable, @CachePut and @CacheEvict

The annotations behave the same on Redis as on any other provider:

@Service
public class BookService {

    @Cacheable(value = "books", key = "#isbn")
    public Book findByIsbn(String isbn) {
        return repository.findByIsbn(isbn);   // only runs on a miss
    }

    @CachePut(value = "books", key = "#book.isbn")
    public Book update(Book book) {
        return repository.save(book);         // always runs, then caches
    }

    @CacheEvict(value = "books", key = "#isbn")
    public void delete(String isbn) {
        repository.deleteByIsbn(isbn);
    }
}

Naming the key explicitly is worth the few characters. Without a key attribute Spring derives one from the method's parameters, which means an entity passed whole becomes part of the key and its equals and hashCode become load-bearing. key = "#book.isbn" removes that dependency.

Two behaviours to know before they surprise you. First, the annotations work through proxies, so a call from one method of a bean to another method of the same bean never passes through the proxy and is never cached. Second, ten concurrent requests for a cold key all run the loader; @Cacheable(sync = true) makes the rest wait for the first, which is the abstraction's answer to the thundering herd problem. Because @Cacheable checks the cache and only calls the method on a miss, what you are building here is a read-through cache — see our guide to cache-aside, write-through and write-behind for when a different pattern fits better.

TTL: One Default for Everything, and How to Get Per-Cache

The simple case is a property, and it applies to every cache the manager builds:

spring:
  cache:
    type: redis
    cache-names: books,authors
    redis:
      time-to-live: 10m

Different caches usually want different lifetimes, though, and a single property cannot express that. Per-cache TTL comes from a RedisCacheManagerBuilderCustomizer bean:

@Configuration(proxyBeanMethods = false)
public class CacheConfig {

    @Bean
    RedisCacheManagerBuilderCustomizer cacheTtlCustomizer() {
        return builder -> builder
            .withCacheConfiguration("books",
                RedisCacheConfiguration.defaultCacheConfig()
                    .entryTtl(Duration.ofMinutes(30)))
            .withCacheConfiguration("sessions",
                RedisCacheConfiguration.defaultCacheConfig()
                    .entryTtl(Duration.ofMinutes(5)));
    }
}

A note on the official documentation here. The Spring Data Redis reference currently shows this snippet in its TTL section:

// From the Spring Data Redis reference — this does not compile.
RedisCacheConfiguration.defaultCacheConfig().enableTtl(Duration.ofMinutes(5));

There is no enableTtl method on RedisCacheConfiguration. The correct call is entryTtl(Duration), as used above. If you copied the docs and got a compile error, the docs were wrong, not you.

Spring Data Redis also supports genuinely per-entry TTL, computed from the key and value, through RedisCacheWriter.TtlFunction — introduced in Spring Data Redis 3.2.0:

enum ExpiryByType implements RedisCacheWriter.TtlFunction {

    INSTANCE;

    @Override
    public Duration getTimeToLive(Object key, @Nullable Object value) {
        return value instanceof Session
            ? Duration.ofMinutes(5)
            : Duration.ofHours(6);
    }
}

// RedisCacheConfiguration.defaultCacheConfig().entryTtl(ExpiryByType.INSTANCE)

This is worth knowing about because it is the thing readers coming from a local cache are usually told they cannot have. A fixed Duration is simply wrapped in a TtlFunction internally, so the two forms are the same mechanism.

The Trap: One Bean Silently Disables Every Property

This is the most expensive thing on the page, because it fails silently and the symptom looks like Redis misbehaving.

Spring Boot's Redis cache auto-configuration resolves its configuration like this:

return redisCacheConfiguration.getIfAvailable(
        () -> createConfiguration(cacheProperties, classLoader));

getIfAvailable(supplier) only calls the supplier when no bean exists — and createConfiguration is the only place Spring Boot reads time-to-live, key-prefix, use-key-prefix and cache-null-values. So the moment you declare your own RedisCacheConfiguration bean, typically to change the serializer:

@Bean
RedisCacheConfiguration cacheConfiguration() {
    return RedisCacheConfiguration.defaultCacheConfig()
        .serializeValuesWith(SerializationPair.fromSerializer(
            new GenericJackson2JsonRedisSerializer()));
}

…all four of those properties stop working. No warning, no log line. Your time-to-live: 10m is still in application.yml, still spelled correctly, and no longer has any effect — entries never expire again.

The fix is to set everything on the bean once you have decided to declare one:

@Bean
RedisCacheConfiguration cacheConfiguration() {
    return RedisCacheConfiguration.defaultCacheConfig()
        .entryTtl(Duration.ofMinutes(10))
        .disableCachingNullValues()
        .serializeValuesWith(SerializationPair.fromSerializer(
            new GenericJackson2JsonRedisSerializer()));
}

Two properties survive, because they are applied to the builder rather than the configuration: spring.cache.redis.enable-statistics and spring.cache.cache-names both keep working. Everything else moves to the bean.

Serialization: The Default Is Java Serialization

Out of the box, keys are serialized with StringRedisSerializer and values with JdkSerializationRedisSerializer. Spring Boot does not merely inherit that value serializer — it sets it explicitly in auto-configuration.

Three consequences follow, and all three tend to arrive late:

  • Every cached class must implement Serializable, including everything it transitively references.
  • Values are opaque binary. Reading them from redis-cli tells you nothing, which makes debugging a cache correctness problem considerably harder than it needs to be.
  • The format is tied to the class. Add a field, and previously cached entries may fail to deserialize after a deploy — a cold cache at best, an exception storm at worst.

Most teams switch to JSON. On Spring Boot 3.x with Jackson 2 that means GenericJackson2JsonRedisSerializer, and this is where the second trap lives.

The LocalDateTime failure. The no-argument constructor of GenericJackson2JsonRedisSerializer builds its own ObjectMapper — it does not reuse the one Spring Boot auto-configured for your application. Spring Boot's mapper has the JSR-310 module registered; this one does not. So the first entity you cache carrying a LocalDateTime, LocalDate or Instant throws an InvalidDefinitionException reading roughly "Java 8 date/time type java.time.LocalDateTime not supported by default: add Module 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' to enable handling."

Pass a mapper that has the module:

@Bean
RedisCacheConfiguration cacheConfiguration() {
    ObjectMapper mapper = JsonMapper.builder()
        .addModule(new JavaTimeModule())
        .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
        .activateDefaultTyping(
            BasicPolymorphicTypeValidator.builder()
                .allowIfBaseType(Object.class).build(),
            ObjectMapper.DefaultTyping.NON_FINAL)
        .build();

    return RedisCacheConfiguration.defaultCacheConfig()
        .entryTtl(Duration.ofMinutes(10))
        .serializeValuesWith(SerializationPair.fromSerializer(
            new GenericJackson2JsonRedisSerializer(mapper)));
}

Default typing is what lets a JSON document deserialize back into its original concrete class rather than a LinkedHashMap. It also means the serializer will instantiate whatever type the stored document names, so the validator above is not optional decoration — keep it narrow. For the wider picture on encoding formats, see data serialization codecs and the serialization glossary entry.

Key Prefixes

By default every cache entry is written under cacheName::key — the cache name, two colons, then the key. Spring Boot's reasoning is worth quoting: the prefix exists "so that, if two separate caches use the same key, Redis does not have overlapping keys and cannot return invalid values."

You can change the prefix without giving it up:

// static: "prod:books::978-0134685991"
RedisCacheConfiguration.defaultCacheConfig()
    .prefixCacheNameWith("prod:");

// computed: full control over the shape
RedisCacheConfiguration.defaultCacheConfig()
    .computePrefixWith(cacheName -> "prod:" + cacheName + ":");

A static prefix is the usual choice when several applications, or several environments, share one Redis instance. disableKeyPrefix() exists but comes with a warning in its own javadoc: without a prefix, Cache.clear() "might result in unintended removal of keys in Redis," so it should only be used against a dedicated instance. The next section explains why that is worse than it sounds.

Caching Nulls, and What Happens When You Turn It Off

Null caching is on by default, and it is usually right. If findByIsbn returns null for a missing book, caching that null stops every subsequent lookup for the same missing ISBN from reaching the database — the defence against cache penetration, where requests for keys that do not exist pass straight through the cache every time.

Turning it off has a sharper edge than the property name suggests. With disableCachingNullValues(), a put of a null value does not quietly skip the write: it errors. Nothing is written, nothing is removed, and an existing key keeps its old value. A cache that was holding a stale entry goes on holding it.

spring:
  cache:
    redis:
      cache-null-values: false   # decide this deliberately

Time to Idle, and Why It Leaks

Plain TTL resets only when an entry is written. An entry read a thousand times still expires on schedule. Time to idle resets the clock on reads as well, which is what you usually want for session-like data.

Spring Data Redis supports it, but it is opt-in and it needs a TTL configured alongside it:

RedisCacheConfiguration.defaultCacheConfig()
    .entryTtl(Duration.ofMinutes(30))
    .enableTimeToIdle();

Two constraints come with it. The implementation reads entries with the Redis GETEX command, which requires Redis 6.2.0 or later — and no version check is performed, so enabling it against an older server produces a command execution exception at runtime rather than a startup failure.

The second constraint is subtler and is the reason this section exists. Time to idle only holds if the entry is always reached through the cache abstraction. A read through RedisTemplate or a Spring Data repository issues a plain GET, which does not reset the timeout — so an entry can expire moments after being read, because it was read the wrong way. Spring's own documentation puts it flatly: an entry "must be consistently accessed with (TTL) expiration on every read or write operation. There are no exceptions to this rule." If your application touches the same keys through both @Cacheable and a repository, time to idle will not behave the way you expect.

Metrics: Off by Default, Twice

A cache you cannot measure is a cache you cannot tune, and Redis caching in Spring Boot reports nothing until you ask for it — in two separate places.

First, statistics collection:

spring:
  cache:
    type: redis
    cache-names: books,authors
    redis:
      enable-statistics: true

Second, Actuator only binds caches that exist at startup. Caches created on the fly are not instrumented unless registered explicitly through a CacheMetricsRegistrar bean. In practice this means enable-statistics: true and cache-names are a pair: set the first without the second and the cache.gets and cache.puts metrics for your most important caches will never appear.

One interpretation caveat. RedisCache#getStatistics() returns local hits and misses — counted in the JVM that asked, not across the cluster. Four replicas produce four independent hit rates, and the number you want is an aggregation of all of them. This surprises people precisely because the cache itself is shared; the counters are not. Our guide to Redis client metrics in Java covers the wider instrumentation picture.

@CacheEvict(allEntries = true) Runs KEYS

@CacheEvict(allEntries = true) calls Cache.clear(), and the default cache writer implements that with KEYS followed by DEL. KEYS is O(N) over the entire keyspace and blocks the server while it runs. Spring Data Redis says so directly: it "can cause performance issues with large keyspaces."

On a small cache nobody notices. On a production Redis holding a few million keys, a scheduled job that clears a cache every ten minutes is a recurring latency spike with no obvious cause. The documented fix is a SCAN-based batch strategy:

@Bean
RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
    RedisCacheWriter writer = RedisCacheWriter.nonLockingRedisCacheWriter(
            connectionFactory, BatchStrategies.scan(1000));

    return RedisCacheManager.builder(writer)
        .cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10)))
        .build();
}

Two related notes. The default writer is lock-free, which improves throughput but makes putIfAbsent and clean non-atomic, since each needs several commands. And when locking is used, it "applies on the cache level, not per cache entry" — a clear on one cache blocks that cache, not individual keys. For the manual equivalents, see FLUSHALL, FLUSHDB and clearing a cache from Java.

Spring Boot 4: What Moved

The reassuring half first: no caching property changed. All six properties in the table above are byte-identical between Spring Boot 3.5 and 4.1. Your application.yml needs no edits.

The Java does. Three things moved or changed:

WhatSpring Boot 3.5Spring Boot 4.x
RedisCacheManagerBuilderCustomizero.s.boot.autoconfigure.cacheo.s.boot.cache.autoconfigure
Properties classRedisPropertiesDataRedisProperties
JSON libraryJackson 2 (com.fasterxml.jackson)Jackson 3 (tools.jackson)

The package move is a compile break on upgrade — an import fix, but one that will not be caught by a configuration review.

Jackson 3 is the interesting one, because it removes one trap and adds another. Spring Data Redis 4.0 uses Jackson 3 as its primary JSON library, and JSR-310 support is now built into jackson-databind rather than living in a separate module — so the LocalDateTime failure described earlier simply does not happen on Spring Boot 4. In its place: the Jackson 3 serializer is GenericJacksonJsonRedisSerializer, and unlike its predecessor it does not enable default typing by default. Cache values typed as Object or a polymorphic base that round-tripped correctly on Spring Boot 3 will come back as LinkedHashMap after the upgrade. Enable it deliberately through the builder if you need it.

There is a wire-format consideration too: Jackson 3 can produce JSON that differs from Jackson 2's output, and Spring's guidance is to keep reading existing values with the old serializer until they have been migrated or expired.

One last behavioural change worth knowing before it bites: in Spring Data Redis 4.0, if the connection factory is reactive, cache put, evict and clear operations become asynchronous and may complete after the calling method returns. Cache.evictIfPresent and Cache.invalidate stay synchronous. To make everything synchronous again, build the writer with immediateWrites(). Our guide to Redisson on Spring Boot 4 covers the equivalent version matrix on the Redisson side.

When to Add Redisson

Everything above is Spring Data Redis, and for a straightforward cache it is enough. There are three situations where teams reach past it, and it is worth being specific about which is which rather than pitching a wholesale replacement.

Per-entry TTL and max-idle without writing a TtlFunction. Redisson's RMapCache takes both per entry directly:

RMapCache<String, Book> books = redisson.getMapCache("books");
books.put("978-0134685991", book, 30, TimeUnit.MINUTES, 10, TimeUnit.MINUTES);

A near cache. If reads dominate, keeping hot entries in local memory and invalidating them over pub/sub avoids the network hop entirely. RLocalCachedMap is that pattern, and its eviction can be backed by Caffeine — the library many readers already trust:

LocalCachedMapOptions<String, Book> options = LocalCachedMapOptions.<String, Book>name("books")
    .cacheProvider(CacheProvider.CAFFEINE)
    .syncStrategy(SyncStrategy.INVALIDATE)   // the default
    .cacheSize(10_000)
    .timeToLive(Duration.ofMinutes(30));

RLocalCachedMap<String, Book> books = redisson.getLocalCachedMap(options);

Both of the above are open-source under Apache 2.0, as is RedissonSpringCacheManager, the drop-in CacheManager that takes per-cache TTL and max-idle:

@Bean
CacheManager cacheManager(RedissonClient redisson) {
    Map<String, CacheConfig> config = new HashMap<>();
    // ttl = 30 minutes, maxIdleTime = 10 minutes
    config.put("books", new CacheConfig(30 * 60 * 1000, 10 * 60 * 1000));
    return new RedissonSpringCacheManager(redisson, config);
}

One licensing line to be exact about, because it is easy to get wrong in either direction: RLocalCachedMap used directly is open-source, but putting a near cache behind @Cacheable — RedissonSpringLocalCachedCacheManager — requires Redisson PRO, as do the clustered and V2 cache managers. The free path is RedissonSpringCacheManager for the annotations plus RLocalCachedMap where a near cache is genuinely needed.

Distributed primitives on the same connection. Locks, semaphores, rate limiters and 60-plus collections, without a second client library. If your service already caches and also needs a distributed lock, that consolidation is usually the real argument. See when to add Redisson and when to migrate for an honest split, and the near cache entry for the pattern itself.

If none of those three apply, Spring Data Redis configured as described above is the right answer and adding a second client is not.

Frequently Asked Questions

How Do You Set Up a Redis Cache in Spring Boot?

Add spring-boot-starter-cache and spring-boot-starter-data-redis, annotate your application class with @EnableCaching, set spring.cache.type=redis, and annotate methods with @Cacheable. That produces a working cache — but set spring.cache.redis.time-to-live as well, because entries never expire by default, and consider replacing the default Java serializer with JSON.

Which Cache Is Best for Spring Boot?

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

Is Redis an L1 or L2 Cache?

Neither, in the CPU sense — those terms describe processor caches. In application architecture Redis is normally the L2 or second-level cache: a shared tier behind an optional in-process L1 cache. That is exactly the two-tier arrangement a near cache implements, and it is also the role Redis plays as a Hibernate second-level cache.

Why Are My Cached Entries Never Expiring?

Two likely causes. Either spring.cache.redis.time-to-live was never set — there is no default expiry — or you declared your own RedisCacheConfiguration bean, which makes Spring Boot ignore that property along with key-prefix, use-key-prefix and cache-null-values. If you have such a bean, set the TTL on it with entryTtl(Duration).

How Do You Set a Different TTL Per Cache in Spring Boot?

Register a RedisCacheManagerBuilderCustomizer bean and call withCacheConfiguration(name, RedisCacheConfiguration.defaultCacheConfig().entryTtl(duration)) once per cache. The spring.cache.redis.time-to-live property applies a single value to every cache and cannot vary by name. For TTL that varies per entry rather than per cache, use RedisCacheWriter.TtlFunction.

Next Steps

A cache configured with an explicit TTL, a JSON serializer, statistics switched on and a SCAN-based clear strategy will behave predictably in production, which is more than the defaults offer. From here, cache invalidation and the cache-aside pattern are the concepts worth reading next, and distributed caching in Java covers the wider architecture.

If per-entry TTL, a near cache or distributed locks on the same connection are on your list, try Redisson PRO free or compare the editions in the feature comparison.