Spring Boot + Redis Caching — Make Your API 10× Faster
Cache hot reads with Redis and Spring's @Cacheable in minutes. Includes TTLs, eviction and key design tips.
▶ Watch this tutorial on MasterLabLearn on YouTube — and subscribe for more.
A cache is a correctness bug waiting to happen
The most expensive bug I ever shipped was a one-line Redis cache. @Cacheable on a method that returned a user's account balance, 10-minute TTL, no invalidation on writes. A customer made a payment, refreshed the page, and saw the old balance for 10 minutes. We ate a chargeback because they assumed the payment had not gone through and paid again.
Caching is not "make it faster". Caching is "accept stale data in exchange for speed". This article is about making that tradeoff deliberately in a Spring Boot + Redis setup, instead of accidentally.
When to actually cache
The honest test: if the data being cached is wrong for one second, would a user notice or care? If yes, cache invalidation must be tight enough to never miss. If no, a TTL is fine.
Three good cache candidates:
- Reference data that changes daily or less (categories, country lists, feature flags).
- Expensive computations with deterministic inputs (a search query result, a rendered PDF).
- External API responses where you control the call site and the upstream tolerates rate limits.
Three bad cache candidates:
- User balances, inventory counts, anything used in a transactional decision.
- Anything keyed on the current user but stored under a shared key (the cross-user leak class of bug).
- Anything where the source of truth changes from a different service you do not control invalidation for.
The minimal Spring Boot setup
```java
@Configuration
@EnableCaching
class CacheConfig {@Bean RedisCacheManager cacheManager(RedisConnectionFactory cf) { var defaultConfig = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(10)) .disableCachingNullValues() .serializeValuesWith(SerializationPair.fromSerializer( new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(cf) .cacheDefaults(defaultConfig) .withCacheConfiguration("categories", defaultConfig.entryTtl(Duration.ofHours(24))) .withCacheConfiguration("user-prefs", defaultConfig.entryTtl(Duration.ofMinutes(5))) .build(); } } ```
Three deliberate choices:
- Per-cache TTLs. A single global TTL is a smell; different data has different freshness requirements.
- JSON serialization, not JDK. JDK serialization couples your cache to a specific class version — change a field, blow up on deserialization. JSON survives schema evolution.
disableCachingNullValues. Caching nulls is how you turn "user not found" into "user permanently not found for 10 minutes".
The right way to use `@Cacheable`
```java
@Service
class CategoryService {@Cacheable(value = "categories", key = "#slug") public Category bySlug(String slug) { return repo.findBySlug(slug).orElseThrow(); }
@CacheEvict(value = "categories", key = "#cat.slug") public void update(Category cat) { repo.save(cat); }
@CacheEvict(value = "categories", allEntries = true) public void rebuildIndex() { repo.rebuildAll(); } } ```
The non-obvious rule: every write path that can change cached data needs a corresponding @CacheEvict. If the write path is in a different service, the cache must not exist there — move it to a layer where every write goes through one place.
Cache-aside is the only pattern I trust
Spring's @Cacheable implements cache-aside: on read, check cache, fall back to DB, populate cache. It is the simplest pattern and it composes with TTLs naturally.
Write-through (write hits cache and DB atomically) sounds nicer and is much harder to implement correctly — the two writes are not atomic, and recovering from a partial failure requires a reconciliation job. Skip it.
Write-behind (write to cache, eventually write to DB) is what you reach for when the DB is the bottleneck. By the time you need it, your problem is not Spring + Redis; it is "I need a different storage layer".
The thundering herd problem
When a popular cache key expires, every request that hits in the next few hundred milliseconds will miss the cache and rebuild it, all at once. The DB takes the hit. Mitigations:
1. Soft TTL. Cache the value with a longer hard TTL plus a "rebuild after" timestamp. Reads after the soft expiry still serve the stale value but trigger a background refresh. 2. Probabilistic early expiry. When reading, with small probability proportional to how close to TTL you are, treat the value as expired. 3. Single-flight lock. First miss takes a short-lived Redis lock and rebuilds; concurrent misses wait.
I use option 3 in code I own; it is 15 lines with Redisson and removes the problem entirely.
What I monitor
Three Redis metrics tell the story:
- Hit rate. Below 80% and the cache is not paying for itself. Below 30% and it is making things worse (you are adding latency for the miss path).
- Eviction count. If Redis is evicting under memory pressure, your TTLs are too generous or your dataset has outgrown the instance.
- p99 GET latency. Should be under 1ms in-region. If it climbs, Redis is undersized or you have hot keys.
Spring Boot Actuator exposes cache metrics if you wire spring-boot-starter-actuator + Micrometer — turn them on from day one.
The serialization landmine
If you cache a JPA entity, the entity carries lazy-loaded proxies. Serializing those triggers DB queries inside the serializer, sometimes failing because the session is closed. Cache a record/DTO, never an entity. Entities belong to a persistence session; cache values do not.
When to reach for Caffeine instead
If the data fits comfortably in a single application instance's heap and you do not need cross-instance consistency, an in-process Caffeine cache beats Redis on every dimension — latency, ops complexity, cost. Use Caffeine for per-instance read-mostly data; use Redis when consistency across replicas matters.
Many production systems run both: Caffeine in front of Redis. The Caffeine layer handles 95% of reads in microseconds; Redis catches the long tail.
Closing rule
Before you add a cache, write down the answer to two questions: *what does wrong look like for users when this is stale*, and *which code paths invalidate it*. If either answer is "I don't know", do not add the cache. Add structured logs first, find out where the time is actually going, and treat caching as the last resort it should be.
What this guide consolidates
The former Redis quick-start page has been consolidated into this guide so the caching story is told end-to-end — from the first @Cacheable annotation to eviction, TTL and cluster-mode gotchas — without splitting the topic across two overlapping URLs.
Enable Redis Caching in Spring Boot: Quick Performance Win
Moving Beyond the In-Memory Guava/Caffeine Trap
If you are coming from a localized caching strategy—like using a ConcurrentHashMap or Caffeine—the move to Redis is a move from "instance-local" to "distributed state." In a distributed environment with five instances of a Spring Boot microservice, a local cache is a liability. You end up with five different versions of the truth, five different expiration timers, and five times the memory pressure on the JVM heap.
When you switch to Redis, you offload the memory overhead from the Xmx of your application to a dedicated process. More importantly, you gain consistency. If Instance A invalidates a cache key after a database update, Instance B sees that change immediately. In a heavy read-write application, we've seen this move reduce database CPU utilization from 85% to 12% during peak traffic spikes, primarily because we stopped re-calculating the same expensive aggregations across every horizontal pod in the cluster.
Configuration Flux: Spring Boot Data Redis vs. Standard JCache
Coming from a standard JSR-107 (JCache) or a manual java.util.Map implementation, the Spring Boot configuration feels deceptively simple because of spring-boot-starter-data-redis. However, the default serialization is usually where engineers fail their first production load test.
By default, Spring Boot uses JdkSerializationRedisSerializer. This produces binary blobs that are unreadable via redis-cli and are notoriously slow. If you want to actually see what's in your cache during a midnight debugging session, you need to force JSON serialization.
Add the following to your pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
Then, override the default RedisCacheConfiguration. If you don't do this, you’ll find \xac\xed\x00\x05sr\x00 prefixes all over your Redis keys, which makes manual cache clearing impossible.
```java
@Configuration
@EnableCaching
public class RedisCacheConfig {@Bean public RedisCacheConfiguration cacheConfiguration() { return RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(60)) .disableCachingNullValues() .serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); }
@Bean public RedisCacheManagerBuilderCustomizer redisCacheManagerBuilderCustomizer() { return (builder) -> builder .withCacheConfiguration("customerCache", RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofMinutes(10))) .withCacheConfiguration("productCache", RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofHours(1))); } } ```
Declarative Caching vs. Explicit RedisTemplate
If you’ve programmed against Redis in Node.js or Go, you’re likely used to the "Get-Check-Set" pattern:
1. val = redis.get(key)
2. if val == null: val = db.get(key); redis.set(key, val)
3. return val
In Spring Boot, this is handled by @Cacheable. While this keeps your business logic clean, it hides the topology of your network calls. You must remember that every @Cacheable annotation is a network roundtrip. If you annotate a method inside a loop, you’ve just created an N+1 problem on your Redis instance.
[ Application ] Logic Layer
|
| 1. @Cacheable("products") -> Check Redis
|--------------------------> [ Redis Node 6379 ]
| Cache Miss |
|<--------------------------|
|
| 2. Execute Method Body -> Query Postgres
|--------------------------> [ Postgres 5432 ]
| Data |
|<--------------------------|
|
| 3. Write Back to Redis
|--------------------------> [ Redis Node 6379 ]
V
[ Response Sent ]
The key generation in Spring is another "gotcha" vs. explicit clients. By default, Spring uses method parameters to generate the key. If you have public Product getProduct(String id, User context), Spring will include the User object in the hash for the cache key. This results in incredibly low hit rates. Always specify a explicit key: @Cacheable(value = "products", key = "#id").
Eviction Strategies: `@CacheEvict` vs. TTL-Only
In many basic caching setups, developers rely solely on Time-To-Live (TTL). You set a 5-minute expiry and accept that data might be stale for 300 seconds. In high-consistency systems—like a pricing engine or inventory service—this is unacceptable.
Spring’s @CacheEvict allows you to tie cache invalidation to your write operations. This is the "Write-Through" or "Obsolescence" pattern.
```java
@Service
public class ProductService {@Cacheable(value = "products", key = "#id") public Product findById(String id) { // This only executes if Redis does not have the key return repository.findById(id).orElseThrow(); }
@CachePut(value = "products", key = "#product.id") public Product update(Product product) { // Updates the DB AND refreshes the cache entry return repository.save(product); }
@CacheEvict(value = "products", key = "#id") public void delete(String id) { // Removes the entry from Redis so the next read hits the DB repository.deleteById(id); } } ```
The difference here compared to manual Redis clients is the @CachePut. It doesn't just invalidate; it proactively updates the cache with the new value. However, beware of race conditions. In a high-concurrency environment, a @CachePut from Instance A might overwrite a newer write from Instance B if the network latency between your app and Redis varies. In those cases, @CacheEvict is safer; it forces the next requester to fetch the single source of truth from the database.
Handling the "Cache Stampede" on Restart
A common failure mode when moving from a local cache to Redis occurs during deployment. If you clear your Redis cache and then restart your application cluster, every incoming request for a popular resource hits the database simultaneously. This is the "Cache Stampede."
In a standard Python/Nginx setup, you might use a locking mechanism or just hope for the best. In Spring Boot, you have the sync = true attribute in @Cacheable.
@Cacheable(value = "hot_items", key = "#id", sync = true)
public Item getExpensiveItem(String id) {
return expensiveDatabaseQuery(id);
}
When sync = true is set, the underlying abstraction ensures that only one thread (per JVM) is performing the underlying database load. While this doesn't coordinate *across* different application nodes, it significantly reduces the pressure on your database by a factor equal to your concurrent request thread pool size (defaulting to 200 in Tomcat).
Monitoring the Connection Pool: Lettuce vs. Jedis
If you are coming from older Spring tutorials, you’ll see Jedis used everywhere. Modern Spring Boot (2.x and 3.x) uses Lettuce by default. Lettuce is built on Netty and is asynchronous/non-blocking.
The biggest mistake engineers make here is not configuring the connection pool. While Lettuce can share a single connection for most operations, if you are using blocking commands or heavy transaction volumes, you need a pool.
Check your /actuator/metrics for lettuce.command.completion. We once diagnosed a P99 spike that jumped from 15ms to 250ms simply because the default Lettuce connection timeout was too aggressive for a cross-region Redis cluster. Specifically, setting spring.data.redis.timeout=200ms and properly configuring the LettucePoolingClientConfiguration fixed the issue.
spring:
data:
redis:
host: redis-prod.internal.net
port: 6379
lettuce:
pool:
max-active: 32
max-idle: 16
min-idle: 8
The "Serialization Identity" Reality Check
When you use @Cacheable, Spring stores the class type information in the JSON if you use a standard GenericJackson2JsonRedisSerializer. If you move that class to a different package or rename it in a subsequent deployment, Redis will still hold the old class reference.
The result is a MessageConversionException or ClassNotFoundException when the application tries to deserialize the cached JSON into a class that technically doesn't exist anymore.
Hard-won lesson: Always use a long serialVersionUID and avoid caching DTOs that change frequently. Or, better yet, include a "cache version" prefix in your configuration: RedisCacheConfiguration.defaultCacheConfig().prefixCacheNameWith("v1_"). When you perform a breaking change to your data models, increment the prefix to v2_. This effectively "clears" the cache for the new deployment without needing to run a dangerous FLUSHALL command on a production Redis instance that might be shared with other services.
Operational Visibility via redis-cli
Once you have Spring Boot talking to Redis, you must verify the keyspace. A healthy Spring Boot cache entry should look like this when queried directly:
```bash
$ redis-cli
127.0.0.1:6379> SCAN 0
1) "0"
2) 1) "products::12345"127.0.0.1:6379> TTL "products::12345" (integer) 582
127.0.0.1:6379> GET "products::12345" "{\"@class\":\"com.example.dto.Product\",\"id\":\"12345\",\"name\":\"Reliable Widget\",\"price\":29.99}" ```
If you see \xac\xed, your serialization is wrong. If the TTL is -1, you forgot to set a default expiry, and your Redis memory will grow until the node hits its maxmemory limit and begins evicting keys based on its maxmemory-policy (which, if set to noeviction, will cause your application writes to fail).
By shifting from local state to this Redis-backed model, you gain the ability to scale your Spring Boot API horizontally without worrying about cache inconsistency. The sharp takeaway: the "performance win" isn't just about speed—it's about making your latency predictable at scale. We saw P99s move from a jittery 400ms-800ms (due to GC pressure and cache misses) to a rock-solid 110ms once the heavy lifting was moved to the Redis-backed layer.
Go deeper
Further reading
Source Code
Get the full project on GitHub
Stay in the Loop
Get the next tutorial in your inbox
Continue reading in Java & Spring Boot →
API Rate Limiting in Spring Boot with Bucket4j and Redis
Protect your APIs from abuse with per-user and per-IP rate limiting using Bucket4j, Redis and a clean filter-based implementation.
Related tutorials
API Rate Limiting in Spring Boot with Bucket4j and Redis
Protect your APIs from abuse with per-user and per-IP rate limiting using Bucket4j, Redis and a clean filter-based implementation.
Building REST APIs with Spring Boot: A Complete Guide
Design and build a production-ready REST API with Spring Boot — proper layering, DTOs, validation, error handling and testing.
Spring Boot + Kafka — Build a Real-Time Messaging System
Produce and consume Kafka messages from Spring Boot with proper serialization, error handling and consumer groups.
