00:00

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 shopApplication
Grinding beans for every orderQuerying the database for every request
The batch of ground coffee on the counterThe cache
Grinding a new batch when it runs out or gets staleRefreshing 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:

  1. Spring builds a key from the method arguments, for example the product ID.
  2. It looks for that key in the cache.
  3. Cache hit: the value is found, so Spring returns it and the method does not run.
  4. Cache miss: the value is not found, so the method runs normally.
  5. 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:

InterfaceRole
CacheOne named cache, such as products, that stores key-value pairs
CacheManagerCreates and looks up Cache objects; each provider (Caffeine, Redis, ...) supplies its own

4. Adding the Dependency

Add the cache starter to your project:

pom.xml
<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.

CacheConfig.java
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

AnnotationWhat it doesMethod runs?
@CacheableReturns the cached value if present; otherwise runs the method and caches the resultOnly on a cache miss
@CachePutRuns the method and stores the result in the cacheAlways
@CacheEvictRemoves one entry, or all entries, from a cacheAlways
@CachingCombines several cache operations on one methodDepends on the operations
@CacheConfigShares settings, such as the cache name, across a whole classNot 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:

ProductService.java
@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:

ProductService.java
@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:

ProductService.java
@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:

ProductService.java
@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:

CategoryService.java
@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:

ProductDto.java
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());
    }
}
ProductService.java
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();
    }
}
ProductController.java
@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:

Console
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 argumentsDefault key
NoneSimpleKey.EMPTY (one shared entry)
One argument, e.g. getProduct(7)The argument itself: 7
Several argumentsA 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:

ProductService.java
// 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:

AttributeCheckedCan use #result?Use it to...
conditionBefore the method runsNoSkip caching for some inputs
unlessAfter the method runsYesSkip caching for some outputs
ProductService.java
// 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:

ProductService.java
@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.

ProviderTypeExpiry and size limitsBest for
ConcurrentMapCache (default)In-memoryNoLearning, demos, tiny apps
CaffeineIn-memoryYesFast local caching in a single instance
RedisDistributedYesSharing a cache across many instances
Ehcache (JCache)In-memory, optionally on diskYesLarge local caches with disk overflow
HazelcastDistributed (in the app cluster)YesClustered apps without a separate cache server

10.1 In-memory vs distributed caches

In-memory (e.g. Caffeine)Distributed (e.g. Redis)
Where data livesInside each application instanceIn a separate cache server
SpeedFastest, no networkVery fast, one network hop
Shared between instancesNo, each instance has its own copyYes, every instance sees the same data
Survives a restartNoYes
Extra infrastructureNoneA 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:

pom.xml
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

Then configure it in application.properties:

application.properties
spring.cache.type=caffeine
spring.cache.cache-names=products,productLists,categories
spring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=10m,recordStats
SettingMeaning
maximumSize=1000Keep at most 1,000 entries per cache; the least useful ones are removed first
expireAfterWrite=10mRemove an entry 10 minutes after it was stored
recordStatsCollect 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:

pom.xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
application.properties
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:

RedisCacheConfig.java
@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:

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

application.properties
management.endpoints.web.exposure.include=health,caches,metrics
RequestShows
GET /actuator/cachesEvery cache and the cache manager it belongs to
GET /actuator/metrics/cache.gets?tag=name:products&tag=result:hitHow many reads found a value
GET /actuator/metrics/cache.gets?tag=name:products&tag=result:missHow many reads had to load the value
GET /actuator/metrics/cache.size?tag=name:productsHow 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:

ProductServiceCachingTest.java
@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 cachingPoor candidates for caching
Data that is read far more often than it changesData that changes on almost every request
Expensive queries, reports and calculationsCheap lookups that are already fast
Responses from slow or rate-limited external APIsData that must always be exactly current, such as account balances
Reference data: countries, categories, settingsLarge one-off results that are rarely requested again
Results that are the same for many usersSensitive 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 @CachePut or @CacheEvict.
  • Name caches by content, such as products or exchangeRates, 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:

  1. @EnableCaching is missing. Without it, the annotations are ignored.
  2. The method is called from the same class. Internal calls skip the Spring proxy, so no caching happens.
  3. The method is private. Proxies can only intercept methods that can be called from outside the class.
  4. The key changes on every call, for example an object argument without proper equals() and hashCode(), or a timestamp parameter.
  5. Caching is switched off with spring.cache.type=none in the active profile.
  6. An unless or condition expression 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 RedisCacheConfiguration with serializeValuesWith(...). 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 refreshAfterWrite reloads 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 LazyInitializationException when 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 @CacheEvict on 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 in TransactionAwareCacheManagerProxy.
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 an Optional again when returning from the cache.
  • CompletableFuture and 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:

  1. Use a shared cache such as Redis, so there is only one copy.
  2. Use short expiry times for in-memory caches, so stale data disappears quickly.
  3. 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?
expireAfterWriteexpireAfterAccess
Timer startsWhen the entry is storedOn every read or write
Popular entriesStill expire on scheduleCan stay forever if read often
Best forData that must refresh regularly, like pricesData 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?

DataTypical expiry
Stock levels, live pricesSeconds to 1 minute, or do not cache
Product details, search results5 to 30 minutes
Categories, menus, settingsHours
Country lists, currency codesA 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.