Spring Boot Caching With @Cacheable (Step-by-Step)
backend
9 min read
Learn how to add caching to any Spring Boot app in minutes using @Cacheable. No Redis, no extra infrastructure - just one annotation and faster responses.

Published By: Nelson Djalo | Date: April 8, 2026
Your Spring Boot endpoint is hammering the database for the same data over and over. Every request runs the same query, returns the same rows, and burns the same CPU cycles. You don't need Redis to fix this. You need one annotation, one starter, and about ten minutes.
This post walks through Spring's built-in caching abstraction with @Cacheable, the in-memory store that ships out of the box, and the gotchas that catch most developers the first time.
Spring Cache is not a cache. It's an abstraction layer that sits in front of one. You annotate a method, Spring wraps it in a proxy, and on each call the proxy checks a cache before invoking the actual method. If the value is there, the method never runs. If it isn't, the method runs and the result gets stored.
The point of this design is that you write the same annotations regardless of what cache lives underneath. You can start with a simple in-memory ConcurrentHashMap, then swap in Caffeine, Ehcache, Hazelcast, or Redis later without changing a single line in your service code. The annotations stay the same. Only the configuration changes.
You only need one dependency to get going. Add this to your pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
Or in Gradle:
implementation 'org.springframework.boot:spring-boot-starter-cache'
That's it. No external services, no Docker container, no config files. Spring Boot auto-configures a ConcurrentMapCacheManager that stores entries in a plain ConcurrentHashMap inside your JVM. It's fast, it's simple, and it's perfect for getting started or for small single-instance apps.
Caching is opt-in. Add @EnableCaching to any @Configuration class - usually your main application class works fine:
@SpringBootApplication
@EnableCaching
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
Without this annotation, every @Cacheable you write will be silently ignored. This is the most common mistake people make on day one. If your cache doesn't seem to be working, check this first.
Here's a typical service method that hits the database every time it's called:
@Service
public class ProductService {
private final ProductRepository repository;
public Product findById(Long id) {
return repository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
}
}
Now add @Cacheable:
@Cacheable("products")
public Product findById(Long id) {
return repository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
}
The first call runs the method and stores the result under the key id in a cache named products. Every subsequent call with the same id returns the cached value without ever touching the repository. That's the entire feature in three words.
By default, Spring uses the method parameters as the cache key. With one parameter, that's just the parameter itself. With multiple parameters, Spring builds a composite key using SimpleKeyGenerator. Most of the time you want more control:
@Cacheable(value = "products", key = "#id")
public Product findById(Long id) {
return repository.findById(id).orElseThrow();
}
The value (or cacheNames) attribute names the cache. The key attribute uses Spring Expression Language (SpEL) to build a custom key. You can reference any parameter by name, access nested fields, or call methods:
@Cacheable(value = "users", key = "#user.email")
public UserProfile loadProfile(User user) {
return profileService.fetch(user.getEmail());
}
@Cacheable(value = "orders", key = "#customerId + '-' + #status")
public List<Order> findOrders(Long customerId, OrderStatus status) {
return orderRepository.findByCustomerAndStatus(customerId, status);
}
SpEL gives you full flexibility. You can reference #root.method.name, #root.target, or any custom bean using @beanName.method(). For most apps, simple parameter references are enough.
@Cacheable skips the method when there's a hit. @CachePut always runs the method and updates the cache with the result. Use it for write operations where you want the cache to reflect the new state:
@CachePut(value = "products", key = "#product.id")
public Product update(Product product) {
return repository.save(product);
}
Now whenever you update a product, the cache entry for that ID gets refreshed with the new data instead of going stale. The next read sees the fresh value without a database hit.
When data changes and you want to drop it from the cache rather than update it, use @CacheEvict:
@CacheEvict(value = "products", key = "#id")
public void delete(Long id) {
repository.deleteById(id);
}
You can also wipe the entire cache by setting allEntries = true. This is useful for bulk operations where individual key tracking gets messy:
@CacheEvict(value = "products", allEntries = true)
public void reloadCatalog() {
catalogService.reimport();
}
There's also beforeInvocation = true if you want the eviction to happen before the method runs (useful when the method might throw an exception and you still want the entry gone).
Sometimes you only want to cache certain results. Maybe you don't want to cache null values, or you only care about expensive lookups. Spring gives you two SpEL hooks for this:
condition - evaluated before the method runs. If false, caching is skipped entirely.unless - evaluated after the method runs. Has access to the return value via #result.@Cacheable(value = "products", key = "#id", condition = "#id > 1000")
public Product findById(Long id) {
return repository.findById(id).orElseThrow();
}
@Cacheable(value = "products", key = "#name", unless = "#result == null")
public Product findByName(String name) {
return repository.findByName(name);
}
The first example only caches products with IDs above 1000. The second caches everything except null results - a common pattern to avoid storing misses.
When you add the cache starter without configuring anything, Spring Boot gives you a ConcurrentMapCacheManager. It's a wrapper around a ConcurrentHashMap per cache name. Entries live in your JVM heap. There's no eviction, no TTL, no size limit. Once something is in there, it stays until you evict it manually or the application restarts.
This is great for prototypes and small apps with bounded data sets. It falls apart when:
For controlled in-memory caching with eviction policies, drop in Caffeine. It's a one-line change in application.properties:
spring.cache.type=caffeine
spring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=10m
The default cache works perfectly until you scale horizontally. The moment you run two instances of your app behind a load balancer, each instance has its own cache. User A hits instance 1 and warms its cache. User B hits instance 2 and gets a cold miss. Worse, when you update data on one instance, the other instance still serves stale entries.
That's when you reach for Redis. It's a shared cache that all instances talk to, so there's one source of truth. The annotations don't change - only the cache manager does. If you're at that stage, read Spring Boot + Redis Caching for the full setup.
Self-invocation doesn't work. Spring caching uses proxies. When you call a @Cacheable method from another method in the same class, the call bypasses the proxy and the cache is never consulted:
@Service
public class ProductService {
@Cacheable("products")
public Product findById(Long id) { ... }
public List<Product> findAll(List<Long> ids) {
// This call SKIPS the cache because it's an internal call
return ids.stream().map(this::findById).toList();
}
}
The fix is to call the cached method from a different bean, or to inject the service into itself, or to use AopContext.currentProxy(). Most teams just split the code into two services.
Caching void methods is pointless. @Cacheable only makes sense for methods that return a value. There's nothing to cache otherwise. Spring won't stop you from annotating a void method, but it won't do anything useful either.
Cached objects must be serializable for some backends. The default in-memory cache stores object references, so this isn't a concern. The moment you switch to Redis, Hazelcast, or any distributed backend, your cached objects need to be serializable. Add implements Serializable and a serialVersionUID early so it's not a surprise later.
Watch out for caching mutable objects. The cache returns the same reference every time. If a caller mutates the returned object, the next caller sees the mutation. Either return defensive copies or use immutable types like records.
Want a deeper dive into Spring Boot internals like this? The Spring Boot Master Class walks through caching, transactions, security, and everything else you need to ship production apps. Or if you're earlier in your journey, check the Spring Boot Roadmap for a structured path.
Do I need Redis to use Spring caching?
No. Spring Boot ships with an in-memory ConcurrentMapCacheManager out of the box. You only need Redis or another external store when you scale to multiple instances or need shared state.
Why isn't @Cacheable working?
The most common reasons: you forgot @EnableCaching, you're calling the cached method from inside the same class (self-invocation), or the method is private. Spring proxies only intercept public methods called from outside the bean.
How do I set a TTL on cached entries?
The default ConcurrentMapCacheManager doesn't support TTLs. Switch to Caffeine with spring.cache.type=caffeine and spring.cache.caffeine.spec=expireAfterWrite=10m to get time-based eviction.
Can I use @Cacheable with method parameters that are objects?
Yes. Use SpEL in the key attribute to reference fields on the object - for example key = "#user.id". If you skip the key and pass an object, Spring uses the object's hashCode and equals, so make sure those are implemented correctly.
What's the difference between @Cacheable, @CachePut, and @CacheEvict?
@Cacheable skips the method if the value is cached. @CachePut always runs the method and updates the cache. @CacheEvict removes entries from the cache. Use @Cacheable for reads, @CachePut for writes that should refresh the cache, and @CacheEvict for deletes or invalidation.
Spring's caching abstraction turns a multi-step performance optimization into a one-line change. Start with the in-memory default, profile your app, and only upgrade to a distributed cache when you actually need one.
Ready to learn Spring Boot the right way? Join the Spring Boot Master Class and build real apps from scratch, or check out Building APIs with Spring Boot for hands-on REST API work.

Skip the generic recommendations. These 9 books changed how I write code, lead teams, and think about systems - from Clean Code to books most devs haven't heard of.

The exact skills, tools, and learning order to go from zero to hired as a Java full stack developer. Covers Spring Boot, React, databases, Docker, and what employers actually look for.

Abstract class or interface? Most Java devs get this wrong. Here's a clear breakdown with a side-by-side comparison table, code examples, and a simple decision rule.
Join thousands of developers mastering in-demand skills with Amigoscode. Try it free today.