Maciej Soliński
Back to the blog

Caching in Spring Boot: What Gets Faster, and What Gets Harder

A practical approach to Caffeine caching: freshness, cache keys, invalidation, concurrency and useful measurements.

Caching in Spring Boot: What Gets Faster, and What Gets Harder

A database query takes 80 milliseconds. The same product is requested repeatedly. Adding a cache looks like an obvious improvement: do the expensive work once, then reuse the result.

The annotation is easy. The difficult part is deciding when the saved result is still correct. A cache adds another copy of information, and that copy has a lifecycle we need to manage.

Start with the freshness requirement

Before choosing a library, ask how old the response may be. A product description and the stock level used to accept an order may have very different requirements.

For this example, assume that a displayed catalogue price may remain unchanged for up to 60 seconds after it was loaded. The checkout process must still obtain the authoritative price. This distinction is a business decision, not something a cache can infer.

Also check whether the workload actually repeats requests for the same keys. A cache with mostly unique lookups may consume memory without removing much work.

A small Caffeine configuration

For a Spring Boot application using its dependency management, the relevant dependencies are:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-cache</artifactId>
    </dependency>
    <dependency>
        <groupId>com.github.ben-manes.caffeine</groupId>
        <artifactId>caffeine</artifactId>
    </dependency>
</dependencies>

Enable annotation-based caching in a configuration class:

import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableCaching
public class CacheConfiguration {
}

Then configure a named cache:

spring:
  cache:
    type: caffeine
    cache-names: cataloguePrices
    caffeine:
      spec: maximumSize=1000,expireAfterWrite=60s,recordStats

The maximum size limits entries, not bytes. A thousand small prices and a thousand large documents have different memory costs. With several caches, this specification applies to each cache built from it. Spring Boot’s caching documentation explains provider configuration.

The key is part of correctness

An illustrative service method could use both the tenant and product as its inputs:

@Cacheable(cacheNames = "cataloguePrices", sync = true)
public ProductPrice findPrice(UUID tenantId, UUID productId) {
    return repository.findPrice(tenantId, productId)
            .orElseThrow(() -> new ProductNotFoundException(productId));
}

This is a fragment of a Spring-managed service. ProductPrice, the repository and the exception are application-specific types; imports and surrounding implementation are omitted. The result should be an immutable snapshot.

For multiple arguments, Spring’s default key includes their values. Tenant identity must come from an authorised context, and the repository must enforce the same scope. Caching is not an access-control mechanism. If currency, customer group or permissions change the result, the lookup and key need to account for them too.

In the default proxy mode, a call from another method on the same object bypasses the caching interceptor. Calling the method through a Spring-managed proxy matters. sync = true asks the provider to coordinate loading for the same key; with a local Caffeine cache, it does not coordinate all application instances. These behaviours are covered in the Spring caching reference.

Expiration is not invalidation

expireAfterWrite makes an entry expire after the configured interval following creation or replacement. Reading it does not extend that interval. expireAfterAccess behaves differently: frequently read data can stay in the cache as long as access continues. The distinction matters when freshness is the goal. See Caffeine’s eviction guide.

If a price update should become visible sooner, plan explicit invalidation. Evicting before a database transaction commits can let another request reload the old value. Even eviction after commit can race with an earlier in-flight read that stores an outdated result. Strict freshness may require versioned entries, coordinated writes or avoiding the cache for that operation.

With several instances, each local cache may contain a different snapshot. That can be acceptable for display data, provided the allowed inconsistency is explicit. A shared cache changes the trade-offs by adding network traffic and another dependency; it does not remove the need for a consistency policy.

Measure more than hit rate

Compare response latency, avoided database work, memory usage and behaviour after restart. Check cold-cache traffic and simultaneous misses, not only a warmed-up demonstration.

Tests should cover repeated reads, separate tenants, invalidation and expiration. A controllable time source is more reliable than sleeping for a minute. For proxy-based caching, at least one test must call the Spring-managed bean; a plain unit test of the method body cannot prove that the annotation works.

A useful cache removes enough expensive work to justify the extra state. If we cannot explain its freshness, keys and invalidation, the annotation is the smallest part of the unfinished design.