Every backend service ends up making the same database query over and over. The product page that 40,000 people load an hour reads the same row 40,000 times. A cache breaks that loop by keeping a copy of the answer somewhere much faster than the database.
Redis is the usual choice for that faster somewhere, and Spring Boot makes the wiring short. Redis was reported in use by 30.7% of professional developers in the Stack Overflow 2025 Developer Survey, against 28% of all respondents. The gap is small, but it points the right way: caching shows up in production work more than it shows up in learning projects.
This guide walks the whole setup. Redis in Docker, the configuration Spring Boot needs, the three caching annotations, and the mistakes that make a cache look like it's working when it isn't.
What Caching Actually Does
A cache is a small, fast store that sits between your application and a slower source of truth. It holds key-value pairs, and it holds them in memory rather than on disk.
The point isn't storage. The point is distance. A database query crosses a network, parses SQL, plans the query, reads pages, and serialises a result. A cache lookup finds a key in memory and returns the bytes. That's the whole trade: you accept the risk of serving slightly stale data in exchange for cutting most of that work.
Cache Hits and Cache Misses
Two outcomes are possible on every read.
A cache hit means the key was present. The application returns the cached copy and never touches the database.
A cache miss means the key wasn't there. The application queries the database, returns the result, and writes a copy into the cache so the next request hits.
The ratio between the two is the only number that tells you whether a cache is earning its keep. A cache with a 5% hit rate is a second network call on every request and nothing else.
Why Redis for a Spring Boot Cache
Redis is an open-source, in-memory key-value store. It supports strings, hashes, lists, sets, and sorted sets, and it can persist to disk if you ask it to.
Three properties make it the default choice behind Spring Boot:
It's shared. An in-process cache like Caffeine lives inside one JVM. Run three instances of your service and you have three separate caches that disagree with each other. Redis is one cache all three read from.
It expires keys for you. Time to live is built in, so entries clean themselves up rather than growing until the heap does.
Spring Data Redis already speaks it. The starter gives you a connection factory, a cache manager, and the annotation support, so most of the integration is configuration rather than code.
What You Need Before You Start
Java Development Kit (JDK) 17 or later
Maven
Docker, for the Redis container
Any IDE, and a REST client such as Postman or curl for testing
The examples below build a small product API backed by an H2 in-memory database, so nothing outside Docker needs installing.
Step 1: Add the Redis Starter
Add this 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-docker-compose</artifactId>
<scope>runtime</scope>
</dependency>
The artifact is spring-boot-starter-data-redis. A lot of older tutorials write spring-boot-starter-redis, which was renamed years ago and will fail to resolve.
The second dependency is optional. It makes Spring Boot start and stop your docker-compose.yml services along with the application, so you don't have to remember to bring Redis up first.
By default the starter uses Lettuce as its Redis client, not Jedis. You don't need to configure that, and you don't need to declare a JedisConnectionFactory bean the way many older guides do.
Step 2: Run Redis in Docker
Create docker-compose.yml in the project root:
services:
redis:
image: redis:7.4.2
ports:
- "6379:6379"
Pin the version. redis:latest means your local cache and your production cache can drift apart without anyone noticing.
Start it:
docker compose up -d
Confirm it's answering:
docker compose exec redis redis-cli ping
You want PONG back. If you get a connection refused, the container isn't running or something else already holds port 6379.
Step 3: Point Spring Boot at Redis
Add this to application.properties:
spring.application.name=spring-boot-redis-cache
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.show-sql=true
spring.cache.type=redis
spring.data.redis.host=localhost
spring.data.redis.port=6379
Two of those lines do the real work. spring.cache.type=redis tells Spring's cache abstraction which backend to use. spring.data.redis.host and spring.data.redis.port tell it where Redis is.
Note the spring.data.redis prefix. Spring Boot 3 moved these keys from spring.redis.*, and properties under the old prefix are silently ignored. If your cache appears to do nothing at all, check this first.
Leave the username and password out entirely when Redis has no authentication, rather than setting them to empty strings.
Step 4: Turn Caching On
Spring's cache abstraction is off until you switch it on. Add @EnableCaching to your application class:
package com.masteringbackend.rediscache;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
@SpringBootApplication
@EnableCaching
public class RedisCacheApplication {
public static void main(String[] args) {
SpringApplication.run(RedisCacheApplication.class, args);
}
}
That annotation registers a post-processor which scans your beans for caching annotations and wraps the matching methods in a proxy. Without it, @Cacheable is a comment.
Step 5: Configure Serialization and TTL
Spring Boot will build a working RedisCacheManager on its own, but the defaults serialise values with Java serialization, which produces unreadable keys and requires every cached class to implement Serializable. JSON is the better default.
Create RedisConfig.java in a config package:
package com.masteringbackend.rediscache.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
@Configuration
public class RedisConfig {
@Bean
public RedisCacheManager redisCacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues()
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaults)
.withCacheConfiguration("PRODUCT_CACHE", defaults.entryTtl(Duration.ofMinutes(2)))
.build();
}
}
Four decisions are encoded there.
entryTtl(Duration.ofMinutes(10)) sets a 10 minute default expiry. Without a TTL, entries live until Redis runs out of memory and starts evicting.
disableCachingNullValues() stops a missing row being cached as null. Cache it and every later lookup for that key returns nothing without asking the database, so a record created a second later stays invisible for the whole TTL.
GenericJackson2JsonRedisSerializer writes JSON and embeds the type, so one cache manager can hold different types. The commonly copied Jackson2JsonRedisSerializer<>(ProductDto.class) locks every cache in the application to a single class and throws as soon as you cache anything else.
withCacheConfiguration overrides the TTL for one named cache. Product data that changes often gets 2 minutes; the default stays at 10 for everything else.
Step 6: Cache a Service Method
Now the annotations. This service reads and writes products through a JPA repository, with the cache in front:
package com.masteringbackend.rediscache.service;
import com.masteringbackend.rediscache.dto.ProductDto;
import com.masteringbackend.rediscache.entity.Product;
import com.masteringbackend.rediscache.repository.ProductRepository;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
@Service
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@CachePut(value = "PRODUCT_CACHE", key = "#result.id")
public ProductDto createProduct(ProductDto productDto) {
Product product = new Product();
product.setName(productDto.name());
product.setPrice(productDto.price());
Product saved = productRepository.save(product);
return new ProductDto(saved.getId(), saved.getName(), saved.getPrice());
}
@Cacheable(value = "PRODUCT_CACHE", key = "#productId")
public ProductDto getProduct(Long productId) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new IllegalArgumentException("No product with id " + productId));
return new ProductDto(product.getId(), product.getName(), product.getPrice());
}
@CachePut(value = "PRODUCT_CACHE", key = "#result.id")
public ProductDto updateProduct(ProductDto productDto) {
Product product = productRepository.findById(productDto.id())
.orElseThrow(() -> new IllegalArgumentException("No product with id " + productDto.id()));
product.setName(productDto.name());
product.setPrice(productDto.price());
Product updated = productRepository.save(product);
return new ProductDto(updated.getId(), updated.getName(), updated.getPrice());
}
@CacheEvict(value = "PRODUCT_CACHE", key = "#productId")
public void deleteProduct(Long productId) {
productRepository.deleteById(productId);
}
}
Notice that every method returns a ProductDto, never the Product entity. That's deliberate, and it matters more than it looks. See the mistakes section below.
How the Three Cache Annotations Differ
| Annotation | Runs the method? | Writes to cache? | Typical use |
|---|---|---|---|
@Cacheable | Only on a miss | On a miss | Reads |
@CachePut | Always | Always | Creates and updates |
@CacheEvict | Always | Removes the key | Deletes |
@Cacheable is the one people expect. @CachePut exists because you sometimes want the method to run every time and refresh the cached copy with its result. Putting @Cacheable on an update method would skip the update entirely once the key was populated.
@CacheEvict also takes allEntries = true, which clears the whole named cache rather than one key. That's the right tool after a bulk import, and the wrong tool on a single-record delete.
Using CacheManager Directly
The annotations cover most cases, but they only fire on calls that pass through the Spring proxy. When you need to touch the cache in the middle of a method, inject CacheManager and work with it directly:
package com.masteringbackend.rediscache.service;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Service;
@Service
public class ProductCacheWriter {
private final CacheManager cacheManager;
public ProductCacheWriter(CacheManager cacheManager) {
this.cacheManager = cacheManager;
}
public void put(Long id, ProductDto dto) {
Cache cache = cacheManager.getCache("PRODUCT_CACHE");
if (cache != null) {
cache.put(id, dto);
}
}
public void evict(Long id) {
Cache cache = cacheManager.getCache("PRODUCT_CACHE");
if (cache != null) {
cache.evict(id);
}
}
}
getCache returns null for a cache name that has never been configured or used, so the null check isn't optional.
Testing the Cache
Start the application and create a product:
curl -X POST http://localhost:8080/api/product \
-H "Content-Type: application/json" \
-d '{"name":"Mechanical Keyboard","price":149.99}'
Read it twice:
curl http://localhost:8080/api/product/1
curl http://localhost:8080/api/product/1
With spring.jpa.show-sql=true on, you should see the select statement in the logs on the first call and nothing on the second. That silence is the cache working.
Confirm it from the Redis side:
docker compose exec redis redis-cli KEYS "PRODUCT_CACHE*"
docker compose exec redis redis-cli GET "PRODUCT_CACHE::1"
docker compose exec redis redis-cli TTL "PRODUCT_CACHE::1"
KEYS lists what's stored, GET shows the JSON, and TTL returns the seconds left before expiry. If TTL comes back as -1, no expiry was applied and your cache configuration isn't being picked up.
Use KEYS on your machine only. On a busy production Redis it blocks the server while it scans; SCAN is the safe equivalent.
Four Mistakes That Break a Redis Cache
Caching JPA entities instead of DTOs. A Hibernate entity carries lazy proxies for its associations. Serialising one outside an open session throws a LazyInitializationException, or worse, quietly drags half the object graph into Redis. Map to a DTO first, every time.
Calling a cached method from inside the same class. The annotations work through a proxy. When getAll() calls this.getProduct(id), the call never leaves the object, so the proxy never sees it and the cache is skipped. Move the cached method to a separate bean.
Forgetting to evict on update paths that bypass the service. A repository call, a scheduled job, or a raw SQL migration can change a row without any annotation firing. The cache then serves the old value until the TTL runs out. Keep every write to a cached entity behind the same service.
Setting no TTL. Redis holds entries forever unless told otherwise. Memory fills, the eviction policy starts dropping keys you wanted, and hit rates fall for reasons nobody can trace. Always set an expiry, even a generous one.
Frequently Asked Questions
What is the difference between @Cacheable and @CachePut?
@Cacheable skips the method body when the key is already cached. @CachePut always runs the method and then writes the result to the cache. Use @Cacheable for reads and @CachePut for creates and updates.
Why is my Spring Boot Redis cache not working?
Check four things in order: @EnableCaching is present on a configuration class, spring.cache.type=redis is set, the connection properties use the spring.data.redis prefix rather than the older spring.redis prefix, and the cached method is being called from another bean rather than from inside its own class.
Do I need Docker to use Redis with Spring Boot?
No. Docker just gives you a clean, disposable Redis with one command. A locally installed Redis or a managed cloud instance works the same way. Only the host, port, and credentials change.
How do I set a different expiry for each cache?
Build the RedisCacheManager with withCacheConfiguration("CACHE_NAME", config) for each named cache, as shown in Step 5. Anything you don't name uses the configuration passed to cacheDefaults.
Should I cache with Redis or with an in-memory cache?
Use an in-memory cache such as Caffeine when one instance of the service is the only reader and the data is cheap to rebuild. Use Redis when several instances need the same view, when entries should survive a restart, or when the cached data is large enough to pressure the heap.
Summary
A Spring Boot Redis cache is five moving parts: the spring-boot-starter-data-redis dependency, a running Redis, the spring.cache.type and spring.data.redis properties, @EnableCaching, and a RedisCacheManager that sets serialization and TTL.
After that, @Cacheable, @CachePut, and @CacheEvict do the work. Cache DTOs rather than entities, keep cached methods in their own beans, evict on every write path, and give every cache an expiry.
Caching is one piece of what separates a service that survives traffic from one that falls over. If you're building out the rest, our Java backend development guide covers the wider stack, and Spring Core vs Spring Boot explains what Spring Boot is automating underneath all of this. For structured practice, the Java backend courses roundup lists where to go next.


