Spring Boot Caching
Users expect fast responses, even when an application handles a lot of data. One of the simplest ways to speed things up is caching: remembering the result of slow work so you do not repeat it. Spring Boot has caching built in, and you can add it to a method with a single annotation.
This tutorial explains what caching is, how it works in Spring Boot, how to use each caching annotation, how to set up Caffeine and Redis, and how to monitor and test your caches.
1. What Is Caching?
Caching means keeping a copy of frequently used data in a fast storage area, called a cache, so that later requests for the same data can be answered quickly.
Without a cache, the application fetches the same data again and again from a slow source such as a database, a remote API or a file. With a cache, it fetches the data once and then reads it from memory.
A real-life example
Think of a coffee shop. Every morning, dozens of customers order the same latte. Instead of grinding fresh beans for each order, the barista grinds a batch at opening time and keeps it ready on the counter.
| Coffee shop | Application |
|---|---|
| Grinding beans for every order | Querying the database for every request |
| The batch of ground coffee on the counter | The cache |
| Grinding a new batch when it runs out or gets stale | Refreshing or expiring cache entries |
2. Why Use Caching?
Caching brings several benefits:
- Faster responses. Reading from memory takes microseconds, while a database query or API call can take tens or hundreds of milliseconds.
- Less load on the database. Fewer repeated queries leave more capacity for real work.
- Fewer calls to external services, which can save money when an API charges per request or has rate limits.
- Better scalability. The application handles more users with the same hardware.
For example, if a product page receives 1,000 requests per minute and the product changes once an hour, caching turns 60,000 database queries per hour into just one.
3. How Caching Works in Spring Boot
Spring caches at the method level. When you call a method marked with @Cacheable, Spring steps in before the method runs:
- Spring builds a key from the method arguments, for example the product ID.
- It looks for that key in the cache.
- Cache hit: the value is found, so Spring returns it and the method does not run.
- Cache miss: the value is not found, so the method runs normally.
- Spring stores the result in the cache under that key, ready for the next call.
Spring does this through a proxy, a wrapper object around your bean. Your code calls the proxy, and the proxy decides whether to call the real method. This is why caching only works when the call comes from another bean.
Spring’s caching is an abstraction: your code uses the same annotations whichever cache technology sits underneath. Two interfaces make this possible:
| Interface | Role |
|---|---|
Cache | One named cache, such as products, that stores key-value pairs |
CacheManager | Creates and looks up Cache objects; each provider (Caffeine, Redis, ...) supplies its own |
4. Adding the Dependency
Add the cache starter to your project:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
This is enough to start. With no other cache library on the classpath, Spring Boot uses a simple in-memory cache based on ConcurrentHashMap.
5. Enabling Caching
Turn caching on by adding @EnableCaching to a configuration class. Without it, caching annotations are silently ignored.
package com.javabytechie.shop.config;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableCaching
public class CacheConfig {
}
You can also put @EnableCaching directly on your @SpringBootApplication class. A separate configuration class keeps cache settings in one place as they grow.
6. Caching Annotations
| Annotation | What it does | Method runs? |
|---|---|---|
@Cacheable | Returns the cached value if present; otherwise runs the method and caches the result | Only on a cache miss |
@CachePut | Runs the method and stores the result in the cache | Always |
@CacheEvict | Removes one entry, or all entries, from a cache | Always |
@Caching | Combines several cache operations on one method | Depends on the operations |
@CacheConfig | Shares settings, such as the cache name, across a whole class | Not applicable |
6.1 @Cacheable
Use it on methods that read data. The first call runs the method; later calls with the same argument return the cached value:
@Cacheable("products")
public ProductDto getProduct(Long id) {
return productRepository.findById(id)
.map(ProductDto::from)
.orElseThrow(() -> new ProductNotFoundException(id));
}
6.2 @CachePut
Use it on methods that update data. The method always runs, and its return value replaces the cached copy, so the next read sees the new data:
@CachePut(value = "products", key = "#result.id")
public ProductDto updatePrice(Long id, BigDecimal newPrice) {
Product product = productRepository.findById(id).orElseThrow();
product.setPrice(newPrice);
return ProductDto.from(productRepository.save(product));
}
Never put @Cacheable and @CachePut on the same method. One skips the method and the other always runs it, so together they behave unpredictably.
6.3 @CacheEvict
Use it on methods that delete data, so the cache does not keep returning something that no longer exists:
@CacheEvict(value = "products", key = "#id")
public void deleteProduct(Long id) {
productRepository.deleteById(id);
}
// Remove every entry in the cache, e.g. after a bulk import
@CacheEvict(value = "products", allEntries = true)
public void importCatalog(List<ProductDto> products) {
// ...
}
By default, eviction happens after the method finishes successfully. Set beforeInvocation = true to evict first, even if the method then throws an exception.
6.4 @Caching
Java does not allow the same annotation twice on one method, so @Caching groups several operations. Here, saving a product refreshes its own entry and clears the cached product list:
@Caching(
put = @CachePut(value = "products", key = "#result.id"),
evict = @CacheEvict(value = "productLists", allEntries = true)
)
public ProductDto saveProduct(ProductRequest request) {
return ProductDto.from(productRepository.save(Product.from(request)));
}
6.5 @CacheConfig
Set the cache name once for the whole class, instead of repeating it on every method:
@Service
@CacheConfig(cacheNames = "categories")
public class CategoryService {
@Cacheable
public CategoryDto getCategory(Long id) { /* ... */ }
@CacheEvict(key = "#id")
public void deleteCategory(Long id) { /* ... */ }
}
7. A Complete Example
Here is a small but complete product service that reads, updates and deletes products, keeping the cache in sync at every step. The data is cached as a record, a simple read-only object:
public record ProductDto(Long id, String name, BigDecimal price) implements Serializable {
public static ProductDto from(Product product) {
return new ProductDto(product.getId(), product.getName(), product.getPrice());
}
}
package com.javabytechie.shop.product;
import java.math.BigDecimal;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.annotation.CacheConfig;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
@Service
@CacheConfig(cacheNames = "products")
public class ProductService {
private static final Logger log = LoggerFactory.getLogger(ProductService.class);
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Cacheable
public ProductDto getProduct(Long id) {
log.info("Loading product {} from the database", id);
return productRepository.findById(id)
.map(ProductDto::from)
.orElseThrow(() -> new ProductNotFoundException(id));
}
@CachePut(key = "#id")
public ProductDto updatePrice(Long id, BigDecimal newPrice) {
Product product = productRepository.findById(id).orElseThrow();
product.setPrice(newPrice);
return ProductDto.from(productRepository.save(product));
}
@CacheEvict(key = "#id")
public void deleteProduct(Long id) {
productRepository.deleteById(id);
}
@Cacheable(cacheNames = "productLists", key = "#category")
public List<ProductDto> getByCategory(String category) {
log.info("Loading category {} from the database", category);
return productRepository.findByCategory(category).stream()
.map(ProductDto::from)
.toList();
}
}
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping("/{id}")
public ProductDto get(@PathVariable Long id) {
return productService.getProduct(id);
}
@PatchMapping("/{id}/price")
public ProductDto updatePrice(@PathVariable Long id, @RequestParam BigDecimal price) {
return productService.updatePrice(id, price);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
productService.deleteProduct(id);
}
}
Call GET /api/products/7 three times and check the log. The message appears only once, because the second and third calls are served from the cache:
INFO ProductService : Loading product 7 from the database
8. Cache Keys
A cache key identifies one entry in a cache, just as a primary key identifies one row in a table. If you do not set a key, Spring builds one from the method arguments:
| Method arguments | Default key |
|---|---|
| None | SimpleKey.EMPTY (one shared entry) |
One argument, e.g. getProduct(7) | The argument itself: 7 |
| Several arguments | A SimpleKey containing all of them |
Use the key attribute to choose the key yourself. It accepts a Spring Expression Language (SpEL) expression, where #name refers to a parameter:
// Use only one of several parameters
@Cacheable(value = "products", key = "#sku")
public ProductDto findBySku(String sku, boolean includeReviews) { /* ... */ }
// Use a field of an object parameter
@Cacheable(value = "prices", key = "#request.productId")
public PriceQuote quote(PriceRequest request) { /* ... */ }
// Combine values into one key
@Cacheable(value = "searchResults", key = "#category + ':' + #page")
public List<ProductDto> search(String category, int page) { /* ... */ }
Objects used as keys must implement equals() and hashCode() correctly. Records, strings and numbers already do.
9. Conditional Caching
Sometimes you only want to cache certain calls or certain results. Two attributes control this:
| Attribute | Checked | Can use #result? | Use it to... |
|---|---|---|---|
condition | Before the method runs | No | Skip caching for some inputs |
unless | After the method runs | Yes | Skip caching for some outputs |
// Only cache searches with a short keyword, which are the most common
@Cacheable(value = "searchResults", condition = "#keyword.length() <= 20")
public List<ProductDto> search(String keyword) { /* ... */ }
// Do not cache empty results, so new products appear right away
@Cacheable(value = "productLists", unless = "#result.isEmpty()")
public List<ProductDto> getByCategory(String category) { /* ... */ }
9.1 sync = true
When a popular entry is missing, many threads may run the slow method at the same moment. sync = true lets only one thread load the value while the others wait for it:
@Cacheable(value = "homepage", key = "'featured'", sync = true)
public List<ProductDto> featuredProducts() { /* expensive query */ }
10. Cache Providers
Because Spring uses an abstraction, you can switch providers by changing dependencies and properties, not code. Spring Boot detects the provider automatically.
| Provider | Type | Expiry and size limits | Best for |
|---|---|---|---|
ConcurrentMapCache (default) | In-memory | No | Learning, demos, tiny apps |
| Caffeine | In-memory | Yes | Fast local caching in a single instance |
| Redis | Distributed | Yes | Sharing a cache across many instances |
| Ehcache (JCache) | In-memory, optionally on disk | Yes | Large local caches with disk overflow |
| Hazelcast | Distributed (in the app cluster) | Yes | Clustered apps without a separate cache server |
10.1 In-memory vs distributed caches
| In-memory (e.g. Caffeine) | Distributed (e.g. Redis) | |
|---|---|---|
| Where data lives | Inside each application instance | In a separate cache server |
| Speed | Fastest, no network | Very fast, one network hop |
| Shared between instances | No, each instance has its own copy | Yes, every instance sees the same data |
| Survives a restart | No | Yes |
| Extra infrastructure | None | A Redis server |
11. Using Caffeine (In-Memory)
Caffeine is a high-performance in-memory cache. Unlike the default cache, it can limit the number of entries and expire them after a set time. Add the dependency:
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
Then configure it in application.properties:
spring.cache.type=caffeine
spring.cache.cache-names=products,productLists,categories
spring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=10m,recordStats
| Setting | Meaning |
|---|---|
maximumSize=1000 | Keep at most 1,000 entries per cache; the least useful ones are removed first |
expireAfterWrite=10m | Remove an entry 10 minutes after it was stored |
recordStats | Collect hit and miss counts for monitoring |
12. Using Redis (Distributed)
When your application runs on several servers, an in-memory cache gives each server its own copy, and the copies can disagree. Redis stores one shared cache that all servers use. Add the dependency:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
spring.cache.type=redis
spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.cache.redis.time-to-live=10m
spring.cache.redis.key-prefix=shop::
All caches now expire after 10 minutes. To give individual caches their own expiry time, add a customizer:
@Configuration
public class RedisCacheConfig {
@Bean
public RedisCacheManagerBuilderCustomizer cacheTimeouts() {
return builder -> builder
.withCacheConfiguration("products",
RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofMinutes(10)))
.withCacheConfiguration("categories",
RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofHours(6)));
}
}
Redis stores bytes, so cached objects must be converted. By default Spring uses Java serialization, which is why ProductDto above implements Serializable. Many teams switch to a JSON serializer so the data is readable and not tied to Java class versions.
For a quick local Redis server, run it with Docker:
docker run -d --name redis -p 6379:6379 redis:7
13. Monitoring Your Caches
A cache you cannot see is hard to tune. With Spring Boot Actuator, you can list caches and track how often they help:
management.endpoints.web.exposure.include=health,caches,metrics
| Request | Shows |
|---|---|
GET /actuator/caches | Every cache and the cache manager it belongs to |
GET /actuator/metrics/cache.gets?tag=name:products&tag=result:hit | How many reads found a value |
GET /actuator/metrics/cache.gets?tag=name:products&tag=result:miss | How many reads had to load the value |
GET /actuator/metrics/cache.size?tag=name:products | How many entries the cache holds (Caffeine) |
The hit ratio is hits divided by all reads. A ratio close to 1.0, such as 0.9, means the cache is doing its job. A low ratio means you may be caching the wrong data, or entries expire too soon. Metrics are only recorded for caches created at startup, which is why the Caffeine example lists them in spring.cache.cache-names.
14. Testing Caching
Verify that the cache really works: the repository should be called only once for repeated reads. Mock the repository and count the calls:
@SpringBootTest
class ProductServiceCachingTest {
@Autowired
private ProductService productService;
@Autowired
private CacheManager cacheManager;
@MockitoBean
private ProductRepository productRepository;
@BeforeEach
void clearCache() {
cacheManager.getCache("products").clear();
}
@Test
void secondCallIsServedFromCache() {
Product phone = new Product(7L, "Phone", new BigDecimal("499.00"));
when(productRepository.findById(7L)).thenReturn(Optional.of(phone));
productService.getProduct(7L);
productService.getProduct(7L);
verify(productRepository, times(1)).findById(7L);
}
@Test
void deleteRemovesEntryFromCache() {
Product phone = new Product(7L, "Phone", new BigDecimal("499.00"));
when(productRepository.findById(7L)).thenReturn(Optional.of(phone));
productService.getProduct(7L);
productService.deleteProduct(7L);
assertThat(cacheManager.getCache("products").get(7L)).isNull();
}
}
Clearing the cache before each test keeps tests independent of each other. @MockitoBean is available from Spring Boot 3.4; on older versions, use @MockBean.
15. When to Use and When to Avoid Caching
| Good candidates for caching | Poor candidates for caching |
|---|---|
| Data that is read far more often than it changes | Data that changes on almost every request |
| Expensive queries, reports and calculations | Cheap lookups that are already fast |
| Responses from slow or rate-limited external APIs | Data that must always be exactly current, such as account balances |
| Reference data: countries, categories, settings | Large one-off results that are rarely requested again |
| Results that are the same for many users | Sensitive data that should not sit in shared memory |
16. Best Practices
- Cache in the service layer, not in controllers or repositories, so every caller benefits.
- Cache small, read-only objects such as records or DTOs, not JPA entities with lazy relationships.
- Always set an expiry time in production. Data without one can stay stale forever.
- Limit the size of in-memory caches so they cannot use up all your memory.
- Evict or update on every write. Each method that changes data should keep the cache in sync with
@CachePutor@CacheEvict. - Name caches by content, such as
productsorexchangeRates, and give each method its own cache to avoid key clashes. - Use a distributed cache such as Redis when you run more than one instance and all of them must see the same data.
- Monitor hit ratios and adjust sizes and expiry times based on real numbers.
- Make the app work without the cache. A cache should make things faster, never be required for correctness.
17. Summary
Caching is one of the quickest ways to make a Spring Boot application faster and more scalable. Add spring-boot-starter-cache, switch it on with @EnableCaching, and use @Cacheable, @CachePut and @CacheEvict to read, refresh and remove cached data. Start with Caffeine for a single instance, move to Redis when several instances must share data, and always set expiry times and watch your hit ratio.
18. Frequently Asked Questions (FAQ)
Q1. Do I need Redis or Caffeine for caching to work?
No. With only spring-boot-starter-cache, Spring Boot uses a simple cache built on ConcurrentHashMap, and caching works right away.
That default is fine for learning and small tools, but not for production: entries never expire and the cache has no size limit, so it can serve stale data and keep growing until memory runs out. Use Caffeine or Redis for real applications.
Q2. Why is my @Cacheable method still called every time?
Go through this checklist:
@EnableCachingis missing. Without it, the annotations are ignored.- The method is called from the same class. Internal calls skip the Spring proxy, so no caching happens.
- The method is
private. Proxies can only intercept methods that can be called from outside the class. - The key changes on every call, for example an object argument without proper
equals()andhashCode(), or a timestamp parameter. - Caching is switched off with
spring.cache.type=nonein the active profile. - An
unlessorconditionexpression excludes the call.
Q3. Two methods use the same cache and return the wrong data. Why?
They produce the same keys. If both methods take a single Long id and share the products cache, they write to the same entries:
@Cacheable("products")
public ProductDto getProduct(Long id) { ... }
@Cacheable("products") // same cache, same key: 7
public List<ReviewDto> getReviews(Long id) { ... }
Calling getReviews(7) may now return a ProductDto, causing a ClassCastException or wrong output. The simplest fix is to give each method its own cache name, such as products and productReviews.
Q4. How do I stop null values from being cached?
Most providers cache null by default, so a “not found” result is remembered even after the record is created. Skip it with unless:
@Cacheable(value = "customers", unless = "#result == null")
public CustomerDto findByEmail(String email) {
return customerRepository.findByEmail(email).map(CustomerDto::from).orElse(null);
}
Methods that return Optional work too. Spring unwraps the Optional, so #result refers to the value inside it. For Redis, you can also disable null caching for all caches with RedisCacheConfiguration.defaultCacheConfig().disableCachingNullValues().
Q5. How do I stop an in-memory cache from using too much memory?
Set a maximum size so old entries are removed when the limit is reached. With Caffeine:
spring.cache.caffeine.spec=maximumSize=5000,expireAfterAccess=30m
Size the limit by memory, not just count: 5,000 small records are fine, but 5,000 large reports may not be. For Redis, set a memory limit and eviction policy on the server itself, for example maxmemory 512mb and maxmemory-policy allkeys-lru.
Q6. Can I give each Caffeine cache a different size or expiry time?
Yes. Register custom caches with a CacheManagerCustomizer:
@Bean
public CacheManagerCustomizer<CaffeineCacheManager> caffeineCustomizer() {
return manager -> {
manager.registerCustomCache("exchangeRates",
Caffeine.newBuilder().expireAfterWrite(Duration.ofMinutes(1)).build());
manager.registerCustomCache("countries",
Caffeine.newBuilder().maximumSize(300).expireAfterWrite(Duration.ofDays(1)).build());
};
}
Caches not registered here still use the shared spring.cache.caffeine.spec settings.
Q7. Why does Redis caching fail with a SerializationException?
Redis stores bytes, and by default Spring uses Java serialization. Any cached class that does not implement Serializable, or contains a field that does not, fails to convert.
- Quick fix: make the cached class, and every class inside it, implement
Serializable. - Better long-term fix: configure a JSON serializer in
RedisCacheConfigurationwithserializeValuesWith(...). JSON is readable in Redis tools and does not break when a class gains a new field.
Also remember that after you change a cached class, old entries in Redis may no longer match it. Clear the affected caches when you deploy such changes.
Q8. What is a cache stampede and how do I prevent it?
A stampede happens when a popular entry expires and hundreds of requests miss the cache at the same moment. All of them run the slow query together, and the database can be overwhelmed.
Ways to prevent it:
@Cacheable(sync = true), so only one thread per instance loads the value while the others wait.- Refresh before expiry. Caffeine’s
refreshAfterWritereloads the entry in the background while still serving the old value. - Add some randomness to expiry times so many entries do not expire together.
Q9. Should I cache JPA entities directly?
It is better to cache DTOs or records instead. Entities cause several problems in a cache:
- Lazy relationships fail with
LazyInitializationExceptionwhen accessed after the original transaction has closed. - Entities are mutable. If code changes a cached entity, every later reader sees the change, even though it was never saved.
- They can be large, pulling in related objects you did not mean to cache.
Convert the entity to a small, read-only record before returning it from the cached method, as ProductDto does in this tutorial.
Q10. How do I clear all caches while the application is running?
Inject the CacheManager and clear every cache it knows:
@Service
public class CacheAdminService {
private final CacheManager cacheManager;
public CacheAdminService(CacheManager cacheManager) {
this.cacheManager = cacheManager;
}
public void clearAll() {
cacheManager.getCacheNames().forEach(name -> {
Cache cache = cacheManager.getCache(name);
if (cache != null) {
cache.clear();
}
});
}
}
With Actuator, you can also send DELETE /actuator/caches to clear everything, or DELETE /actuator/caches/products for one cache. Secure these endpoints, since anyone who can call them can slow down your app.
Q11. How do I turn caching off for tests or local development?
Set the cache type to none in the profile or test where you want it off:
spring.cache.type=none
Your annotations stay in place, but every call goes straight to the method. This is useful when debugging, or for tests that check business logic and should not be affected by results left over from earlier tests.
Q12. Should I put @Cacheable on a controller method?
It works, but the service layer is usually the better place. Service methods are reused by controllers, scheduled jobs and message listeners, so all of them benefit from the cache.
Do not confuse server-side caching with HTTP caching. If you want browsers or CDNs to keep a response, set response headers instead:
@GetMapping("/{id}")
public ResponseEntity<ProductDto> get(@PathVariable Long id) {
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(Duration.ofMinutes(5)).cachePublic())
.body(productService.getProduct(id));
}
The two work well together: the server cache avoids repeated database work, and HTTP caching avoids repeated requests.
Q13. Does caching work together with @Transactional?
Yes, but watch for one trap: cache updates are not part of the database transaction by default. If a @CachePut method stores a value and the transaction then rolls back, the cache holds data that was never saved.
Two solutions:
- Prefer
@CacheEvicton write methods. A removed entry is simply reloaded later, so a rollback cannot leave wrong data behind. - Make the cache manager transaction-aware, so puts and evictions happen only after a successful commit. For example, call
transactionAware()on the Redis cache manager builder, or wrap any cache manager inTransactionAwareCacheManagerProxy.
Q14. Can I use two cache managers, such as Caffeine and Redis, together?
Yes. Define both beans, mark one as @Primary, and choose the other per method with the cacheManager attribute:
@Configuration
@EnableCaching
public class MultiCacheConfig {
@Bean
@Primary
public CacheManager localCacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager();
manager.setCaffeine(Caffeine.newBuilder().maximumSize(1000).expireAfterWrite(Duration.ofMinutes(5)));
return manager;
}
@Bean
public CacheManager sharedCacheManager(RedisConnectionFactory connectionFactory) {
return RedisCacheManager.builder(connectionFactory).build();
}
}
@Cacheable(value = "sessions", cacheManager = "sharedCacheManager")
public SessionDto getSession(String token) { ... }
Q15. Can I cache methods that return Optional or CompletableFuture?
Optional: yes. Spring stores the value inside it, and wraps it in anOptionalagain when returning from the cache.CompletableFutureand reactive types (Mono,Flux): supported since Spring Framework 6.1, but only with a cache that supports asynchronous access. With Caffeine, enable it on the cache manager:
@Bean
public CacheManagerCustomizer<CaffeineCacheManager> asyncCaffeine() {
return manager -> manager.setAsyncCacheMode(true);
}
On older Spring versions, cache the plain value inside the asynchronous flow instead.
Q16. How do I preload (warm up) a cache when the application starts?
Call the cached methods once the application is ready. The results then go into the cache before the first user arrives:
@Component
public class CacheWarmer {
private final CategoryService categoryService;
public CacheWarmer(CategoryService categoryService) {
this.categoryService = categoryService;
}
@EventListener(ApplicationReadyEvent.class)
public void warmUp() {
categoryService.getAllCategories(); // @Cacheable, now stored in the cache
}
}
Keep warm-up small and focused on the most-used data, so startup stays fast.
Q17. I run three instances of my app. How do I keep their caches consistent?
With an in-memory cache, each instance has its own copy. An update handled by instance A evicts the entry only on A, so B and C keep serving the old value. Options:
- Use a shared cache such as Redis, so there is only one copy.
- Use short expiry times for in-memory caches, so stale data disappears quickly.
- Broadcast evictions with a message, for example through Redis pub/sub or Kafka, so every instance removes the entry.
Option 1 is the simplest. Options 2 and 3 suit cases where local caching speed matters most.
Q18. What is the difference between expireAfterWrite and expireAfterAccess?
expireAfterWrite | expireAfterAccess | |
|---|---|---|
| Timer starts | When the entry is stored | On every read or write |
| Popular entries | Still expire on schedule | Can stay forever if read often |
| Best for | Data that must refresh regularly, like prices | Data that rarely changes, like user preferences |
When freshness matters, use expireAfterWrite. expireAfterAccess alone can keep a frequently read entry indefinitely, even after the source data has changed.
Q19. How do I cache data per user?
Include the user in the cache key, so users never see each other’s data:
@Cacheable(value = "recommendations", key = "#userId + ':' + #category")
public List<ProductDto> recommendationsFor(Long userId, String category) {
// ...
}
Avoid relying on hidden context, such as reading the current user inside the method without passing it in. The key would then ignore the user, and one person’s result could be served to everyone. Also think twice before caching sensitive personal data in a shared cache.
Q20. How do I choose a good expiry time?
Ask: how old can this data be before it causes a problem?
| Data | Typical expiry |
|---|---|
| Stock levels, live prices | Seconds to 1 minute, or do not cache |
| Product details, search results | 5 to 30 minutes |
| Categories, menus, settings | Hours |
| Country lists, currency codes | A day or more |
Start conservative, then use your hit-ratio metrics to adjust. Remember that eviction on writes keeps data fresh within one instance, so expiry mainly protects against changes made outside your application.