00:00

Spring Boot Caching: Concepts and Implementations

Last updated:

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 is a performance optimization technique that stores copies of frequently accessed data in a high-speed storage layer, known as a cache, enabling subsequent requests to retrieve the data more quickly. It acts as an intermediate layer between an application and its underlying data sources, such as databases or external APIs.

How a cache answers the same request twice
1st request · cache miss–
2nd request · cache hit–
The first request for product 7 misses the cache, so the data is read from the database and a copy is saved in the cache. The second request for the same product is answered straight from the cache, and the database is not touched. Times are illustrative.

By serving cached data whenever appropriate, caching reduces redundant database queries, minimizes network requests, and decreases the workload on backend systems. Implementing caching can significantly improve application response times, increase throughput, reduce resource consumption, and enhance the overall user experience.

In Spring Boot applications, caching can be implemented using in-memory solutions or distributed caching systems, depending on the application's performance requirements and architecture.

2. How Caching Improves Application Performance

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.

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(() -> new ProductNotFoundException(id));
    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

Spring’s cache annotations are not @Repeatable, so you cannot put two @CacheEvict (or two @Cacheable, or two @CachePut) annotations directly on one method. @Caching groups several operations of the same type. Here, deleting a product removes its own entry and clears the cached product lists:

ProductService.java
@Caching(evict = {
        @CacheEvict(value = "products", key = "#id"),
        @CacheEvict(value = "productLists", allEntries = true)
})
public void deleteProduct(Long id) {
    productRepository.deleteById(id);
}

Different annotations, such as one @CachePut and one @CacheEvict, can be placed directly on the same method without @Caching. Many teams still wrap them in @Caching to keep a method’s cache operations together, as the next example does.

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.cache.annotation.Caching;
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));
    }

    @Caching(put = @CachePut(key = "#id"),
             evict = @CacheEvict(cacheNames = "productLists", allEntries = true))
    public ProductDto updatePrice(Long id, BigDecimal newPrice) {
        Product product = productRepository.findById(id)
                .orElseThrow(() -> new ProductNotFoundException(id));
        product.setPrice(newPrice);
        return ProductDto.from(productRepository.save(product));
    }

    @Caching(evict = {
            @CacheEvict(key = "#id"),
            @CacheEvict(cacheNames = "productLists", allEntries = true)
    })
    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);
    }
}

Every write keeps both caches in sync. updatePrice refreshes the product’s own entry and clears productLists, and deleteProduct removes the entry and clears productLists. Without the second step, getByCategory would keep returning the old price, or a product that no longer exists. The list cache is cleared with allEntries = true because a write receives the product ID, not the category key.

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 = "productsBySku", 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) { /* ... */ }

The SKU lookup uses its own cache, productsBySku. If it shared products with getProduct, the same product would sit under two keys (its ID and its SKU), and @CacheEvict(key = "#id") would never remove the SKU entry. Write methods should clear productsBySku too, for example with @CacheEvict(cacheNames = "productsBySku", allEntries = true).

Objects used as keys must implement equals() and hashCode() correctly. Records, strings and numbers already do.

Tip: expressions such as #sku need parameter names at runtime. From Spring Framework 6.1, that means compiling with the -parameters flag. The Spring Boot Maven and Gradle plugins set it for you, but if you build another way and see an error about a missing parameter name, add -parameters to your compiler settings, or use #p0 / #a0 for the first argument.

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 */ }

sync = true has some limits. It works with one cache only, it cannot be combined with unless, and it cannot be mixed with other cache operations on the same method. Breaking any of these rules makes Spring throw an IllegalStateException when the method is called.

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

Set spring.cache.type to pick a provider explicitly. This avoids surprises when several cache libraries end up on the classpath.

10.1 Setting up Caffeine

Caffeine is a fast in-memory cache with size limits and expiry. Add the library; Spring Boot manages its version:

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

Then configure the caches in application.properties:

application.properties
spring.cache.type=caffeine
spring.cache.cache-names=products,productLists,productsBySku
spring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=10m,recordStats
  • spring.cache.cache-names creates these caches at startup, which also registers their metrics (see section 11).
  • maximumSize=1000 keeps at most 1,000 entries per cache, removing the least useful ones first.
  • expireAfterWrite=10m removes each entry 10 minutes after it was stored, so stale data cannot live forever.
  • recordStats turns on hit and miss counting. Without it, the cache metrics read zero.

Every cache shares this spec. To give one cache its own size or expiry, see FAQ Q6.

10.2 Setting up Redis

Redis keeps the cache in a separate server, so every instance of your application shares the same data. Add the Redis starter:

pom.xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Point it at your Redis server and set a time to live (TTL) for entries:

application.properties
spring.data.redis.host=localhost
spring.data.redis.port=6379

spring.cache.type=redis
spring.cache.cache-names=products,productLists,productsBySku
spring.cache.redis.time-to-live=10m
spring.cache.redis.enable-statistics=true

For more control, define a RedisCacheConfiguration bean. Spring Boot uses it as the default for every Redis cache:

RedisCacheConfig.java
package com.javabytechie.shop.config;

import java.time.Duration;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;

@Configuration
public class RedisCacheConfig {

    @Bean
    public RedisCacheConfiguration redisCacheConfiguration() {
        return RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10))      // expire entries after 10 minutes
                .disableCachingNullValues()             // never store null
                .prefixCacheNameWith("shop:");         // keys look like shop:products::7
    }
}
Note: when this bean exists, Spring Boot uses it instead of spring.cache.redis.time-to-live and the other spring.cache.redis.* entry settings, so set the TTL in the bean. spring.cache.cache-names and spring.cache.redis.enable-statistics still apply.

This configuration keeps Spring’s default Java serialization, which is why ProductDto implements Serializable. To store values as JSON instead, see FAQ Q7.

10.3 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 restartNo, the cache is empty after the application restartsSurvives an application restart. Surviving a Redis restart depends on Redis persistence (RDB snapshots or AOF)
Extra infrastructureNoneA Redis server

11. Monitoring Caches Using Actuator

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 and Redis examples list them in spring.cache.cache-names.

Hit and miss counts read zero? Statistics are off by default. Turn them on for your provider:
  • Caffeine: add recordStats to spring.cache.caffeine.spec.
  • Redis: set spring.cache.redis.enable-statistics=true.

12. 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);
        assertThat(cacheManager.getCache("products").get(7L)).isNotNull();   // cached before the delete

        productService.deleteProduct(7L);

        assertThat(cacheManager.getCache("products").get(7L)).isNull();
    }
}

Clearing the cache before each test keeps tests independent of each other. Checking that the entry is cached before the delete matters: without it, the test would also pass if caching were not working at all.

@MockitoBean is available from Spring Boot 3.4. @MockBean was deprecated in Spring Boot 3.4 and removed in 4.0, so use it only on versions before 3.4.

13. 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

14. 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.

15. 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.