Spring Boot – Caching

July 21, 20265 min readUpdated 8/18/2026

Caching is the cheapest performance win available and the easiest way to serve confidently wrong data. The annotations are three lines; deciding what to cache and when to throw it away is the actual work.

Setup

<!-- Spring Boot 4 modularized autoconfiguration: depending on the cache
     abstraction alone gives you the annotations with NO CacheManager, and
     @Cacheable then silently does nothing. -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
@Configuration
@EnableCaching
public class CacheConfig {

    /** The active, customer-facing menu. */
    public static final String MENU_CACHE = "menu";

    /** The active menu filtered to one product type. */
    public static final String MENU_BY_TYPE_CACHE = "menuByType";

    @Bean
    public ConcurrentMapCacheManager cacheManager() {
        ConcurrentMapCacheManager manager =
                new ConcurrentMapCacheManager(MENU_CACHE, MENU_BY_TYPE_CACHE);
        // Refuse to create caches that were never declared above. Without this, a typo in a
        // @Cacheable name quietly creates a brand new cache that no @CacheEvict ever clears.
        manager.setAllowNullValues(false);
        return manager;
    }
}

Without @EnableCaching, every annotation is inert. No error, no warning — the methods simply run every time. It is the first thing to check when caching "isn't working".

Decide what to cache before you cache it

The pizza API caches the menu and nothing else, and the comment says why:

/**
 * <p>The menu is the right thing to cache and almost the only one here: every visitor loads it, it
 * is identical for all of them, and it changes only when an administrator edits the catalogue. The
 * cart and the orders are cached nowhere — they are per-user and change constantly, and caching
 * them would be a correctness bug dressed as an optimisation.
 */

Three properties make something a good candidate: read often, identical for every caller, changes rarely. The menu has all three. A cart has none of them.

Per-user data is the dangerous case, because caching it usually works in testing — where one person is logged in — and leaks one customer's data to another under load.

The annotations

@Override
@Cacheable(value = CacheConfig.MENU_CACHE, keyGenerator = "methodAwareKeyGenerator")
@Transactional(readOnly = true)
public List<ProductDTO> getMenu() {
    log.debug("Getting the active menu");
    return mapper.mapProductsToProductDTOs(productDAO.findActiveMenu());
}

@Override
@Cacheable(value = CacheConfig.MENU_BY_TYPE_CACHE, key = "#type")
@Transactional(readOnly = true)
public List<ProductDTO> getByType(ProductType type) {
    return mapper.mapProductsToProductDTOs(productDAO.findActiveByType(type));
}
AnnotationDoes
@Cacheablereturn the cached value if present, otherwise run and store
@CacheEvictremove entries
@CachePutalways run, and update the cache with the result
@Cachingcombine several of the above

Keys

The default key is built from all the method arguments. key = "#type" names one explicitly using SpEL — #root.methodName, #result and property paths like #dto.id are all available.

The no-argument case has a trap the pizza API's key generator exists to fix:

/**
 * The default key generator hashes every method argument together. That is reasonable until a
 * no-argument method appears: {@code getMenu()} and any other no-argument method on the same
 * cache both key to {@link SimpleKey#EMPTY}. Naming the method removes the collision.
 */
@Bean
public KeyGenerator methodAwareKeyGenerator() {
    return (target, method, params) ->
            params.length == 0 ? method.getName() : method.getName() + "-" + List.of(params);
}

Two no-argument methods sharing a cache both key to SimpleKey.EMPTY, so the second one serves the first one's data. Everything type-checks and the wrong list comes back.

⚠️ Cache names must match exactly. This is why they are constants:

/**
 * <p>{@code @Cacheable} and {@code @CacheEvict} refer to a cache by a string. Spelling it
 * differently in the two places is the classic caching bug: reads populate {@code "menu"}, writes
 * evict {@code "menus"}, and stale data is served forever with nothing failing.
 */

Eviction is the hard part

Every write path that could invalidate the cache must evict it. Miss one and the system serves stale data indefinitely — and nothing fails, which is what makes it a support ticket rather than an alert.

The pizza API has five methods that can change the menu, and all five carry the same block:

@Override
@Caching(
        evict = {
            @CacheEvict(value = CacheConfig.MENU_CACHE, allEntries = true),
            @CacheEvict(value = CacheConfig.MENU_BY_TYPE_CACHE, allEntries = true)
        })
@Transactional
public ProductDTO updateProduct(UUID id, ProductCreateDTO dto) { /* … */ }

Applied to createProduct, updateProduct, deactivateProduct, deleteProduct and setProductImage. That last one is easy to forget, and its comment explains the consequence:

/**
 * <p>The eviction matters as much as the write. Without it the menu cache keeps serving the
 * old image URL and the admin reasonably concludes the upload silently failed.
 */

allEntries = true clears the whole cache rather than one key. Here that is correct: changing one product changes the menu list, and the by-type cache has an entry per type that may contain it. Evicting precisely would mean working out which entries could reference the product — more code, more ways to be wrong, for a cache with a handful of entries.

⚠️ The proxy rule applies here too

@Service
public class ProductServiceImpl {

    public List<ProductDTO> getMenuTwice() {
        getMenu();      // NOT cached — internal call, the proxy is not involved
        return getMenu();
    }

    @Cacheable("menu")
    public List<ProductDTO> getMenu() { /* … */ }
}

Same mechanism as @Transactional and @Async — lesson 8. A self-invocation bypasses the proxy, so nothing is cached and nothing tells you.

⚠️ ConcurrentMapCacheManager is a single-JVM cache

/**
 * <p>It is an in-memory {@code ConcurrentHashMap}: nothing evicts by time, nothing bounds its size,
 * and each instance has its own copy. That is fine for one process and wrong the moment there are
 * two — instance A evicts on a write and instance B keeps serving the old menu until it restarts.
 */

Three separate limitations, and the third is the one that surprises people in production. With two replicas behind a load balancer, an admin's edit evicts the cache on whichever instance handled the request. The other one carries on serving the old menu.

The fixes:

CacheGives youCost
CaffeineTTL and size boundsstill per-instance
Redisshared across instances, TTLinfrastructure, network hop
# Caffeine: bounded and time-limited, still local
spring.cache.type=caffeine
spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=10m

# Redis: shared
spring.cache.type=redis
spring.data.redis.host=localhost
spring.cache.redis.time-to-live=600000

A TTL is worth having even when your eviction is correct. It bounds how long a missed eviction can hurt you: ten minutes of stale data instead of forever.

Verifying it works

for i in 1 2 3; do curl -s -o /dev/null -w "call $i: %{time_total}s\n" \
  http://localhost:8085/api/products; done

# call 1: 0.003512s   ← populates the cache
# call 2: 0.001593s
# call 3: 0.001646s

Better still, turn on logging.level.org.springframework.cache=TRACE and watch the hits and misses directly. Then test the eviction, which is the part that actually breaks: change a product through the admin API and confirm the menu reflects it immediately.

What to take from this

  • @EnableCaching, or every annotation is silently inert.
  • Cache what is read often, shared, and rarely changed. Never per-user data.
  • Cache names as constants, and a key generator that includes the method name.
  • Every write path evicts. Missing one fails silently and forever.
  • The default cache is per-JVM. Add a TTL, and use Redis if you run more than one instance.

Next: Elasticsearch — search a LIKE query cannot do, and the cost of a second copy of your data.