Caffeine Cache in Spring Boot: A Practical Guide

Published on
September 8, 2026

Caffeine is the usual answer to "cache this method" in a Spring Boot service. Put the Java cache library on the classpath, add @EnableCaching, annotate a method with @Cacheable, and you have a working Caffeine cache in a Spring Boot application, serving from the heap in nanoseconds what used to cost a database round trip.

That first step is genuinely easy. Everything after it is where teams get surprised: one specification governs every cache you own, statistics stay silent unless you ask for them twice over, refreshAfterWrite is reachable from @Cacheable only through a loader that displaces your method body, and the moment you scale to a second instance the cache quietly stops being correct. This guide covers each of those, then shows what the same setup looks like on Valkey or Redis when one JVM is no longer the shape of your deployment.

What Spring Boot Configures When Caffeine Is on the Classpath

Spring Boot detects Caffeine and auto-configures a CaffeineCacheManager from the spring-boot-starter-cache starter. Caches are created at startup from spring.cache.cache-names, and customised by the first of these that it finds, in this order:

  1. A cache specification in the spring.cache.caffeine.spec property
  2. A CaffeineSpec bean
  3. A Caffeine bean

One correction worth making early, because it appears constantly in tutorials: Caffeine is not Spring Boot's default cache. With no caching library present, Spring Boot configures a simple ConcurrentHashMap-backed manager with no eviction and no expiry at all, which is a memory leak with an annotation on it. Caffeine only takes over because you added it. For the admission policy it uses once it does, and how W-TinyLFU compares to plain LRU, the glossary has the detail.

Adding the Caffeine Cache Library to a Spring Boot Application

Two dependencies: Spring's cache starter, and Caffeine itself.

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

Spring Boot's BOM manages the Caffeine version, so no <version> element is needed. Pin one only to override the managed version: at the time of writing that is 3.2.4, which requires Java 11 or above. The 2.x line is what you use on Java 8.

The smallest working configuration is two properties:

spring.cache.cache-names=products,customers
spring.cache.caffeine.spec=maximumSize=500,expireAfterAccess=600s

Wiring the Cache Manager Explicitly

The property form is fine until you need something the specification string cannot express. CaffeineSpec accepts value parameters only, so a weigher, a removalListener, an Expiry or a custom Executor all require code. Statistics are not in that category: recordStats is a valid spec key, though on its own it is only half of what metrics need, as the metrics section below explains.

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CaffeineCacheManager cacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(Caffeine.newBuilder()
                .maximumSize(10_000)
                .expireAfterWrite(Duration.ofMinutes(10))
                .recordStats());
        return manager;
    }
}

One behavioural difference matters here. If you call setCacheNames(...), the set of caches is fixed at startup and a request for an unknown name returns null. If you don't, the manager creates caches lazily on demand, which is convenient in development and hides typos in production: a misspelled name in @Cacheable("prodcuts") silently gets its own cache instead of failing.

Using @Cacheable With Caffeine

The Spring Cache annotations behave here as they do with any provider, and the glossary covers @Cacheable, @CachePut and @CacheEvict in full. Two things are worth pulling out.

@Service
public class ProductService {

    @Cacheable("products")
    public Product findById(Long id) {
        return productRepository.findById(id).orElseThrow();
    }

    @Cacheable(value = "priceQuotes", key = "#sku + ':' + #currency", sync = true)
    public Quote quote(String sku, String currency) {
        return pricingClient.quote(sku, currency);
    }
}

The first is keys. With no key attribute, Spring derives one with SimpleKeyGenerator: a single argument becomes the key itself, several arguments become a SimpleKey tuple, and no arguments become SimpleKey.EMPTY. That is fine until two different methods with same-shaped arguments share one cache name, at which point their keys collide and a SpEL key expression is what separates them.

The second is sync = true, worth setting whenever a value is expensive to compute and its key is hot. Without it, a miss on a popular key lets every concurrent caller through to the origin at once, which is the thundering herd. With it, one caller loads and the rest wait on that result.

Sizing a Caffeine Cache: maximumSize Is Not a Memory Limit

This is the first thing to get right and the easiest to get wrong. maximumSize(10_000) bounds the cache at ten thousand entries. It says nothing about bytes. Ten thousand small DTOs might be two megabytes; ten thousand fully hydrated order aggregates might be two gigabytes, and the only symptom is an OutOfMemoryError under load that never reproduces in staging.

Where entry sizes vary, bound the cache by weight instead:

Cache<String, byte[]> cache = Caffeine.newBuilder()
        .maximumWeight(64L * 1024 * 1024)
        .weigher((String key, byte[] value) -> key.length() + value.length)
        .build();

Two caveats. A weight is computed when the entry is written and stays fixed from then on, so a mutable value that grows afterwards is invisible to the bound. And maximumSize and maximumWeight are mutually exclusive: set one or the other.

Eviction is also approximate in the short term. Caffeine performs maintenance during writes and occasionally during reads instead of on a timer, so a cache configured for a thousand entries may briefly hold slightly more. That is a deliberate trade for amortised O(1) behaviour, and it only ever matters to a test asserting an exact size immediately after a write.

Expiry: expireAfterWrite, expireAfterAccess and expireAfter

Caffeine offers three time-based policies, and the difference between the first two decides whether your cache ever serves stale data.

PolicyClock starts atBounds staleness?Bounds memory?
expireAfterWriteCreation or replacement of the valueYes — it fires whether or not anyone readsOnly incidentally
expireAfterAccessThe last read or writeNo — a key read every few seconds never expiresYes — it drops keys nobody wants
expireAfter(Expiry)Whatever your code computes per entryYes, if the source carries its own validityYes, if you make it

The variable form earns its keep when the data arrives with an expiry attached, such as an OAuth token with an expires_in field. The first two can be set together, and usually should be: expireAfterWrite to cap staleness, expireAfterAccess to evict what nobody wants.

As with size, expiry is amortised into cache operations rather than driven by a background thread, so an expired entry can hold memory, and be counted by estimatedSize(), past its deadline. It is never returned to a reader: expired entries are invisible to reads and writes from the moment they expire. Pass .scheduler(Scheduler.systemScheduler()) if you need prompt removal, and inject a Ticker rather than sleeping in tests that assert on timing.

One Spec for Every Cache, and How to Get Per-Cache TTL

Here is the limitation most teams hit, and the one with the least obvious workaround. spring.cache.caffeine.spec applies to every cache the manager builds. Exchange rates that must be thirty seconds old at most, and a country list that changes twice a decade, get the same TTL and the same maximum size.

The escape hatch is registerCustomCache, which attaches a fully built native cache under a name:

@Bean
public CaffeineCacheManager cacheManager() {
    CaffeineCacheManager manager = new CaffeineCacheManager();

    // the common settings, for every cache not named below
    manager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(1_000)
            .expireAfterWrite(Duration.ofMinutes(5))
            .recordStats());

    manager.registerCustomCache("exchangeRates", Caffeine.newBuilder()
            .maximumSize(200)
            .expireAfterWrite(Duration.ofSeconds(30))
            .recordStats()
            .build());

    manager.registerCustomCache("countries", Caffeine.newBuilder()
            .maximumSize(500)
            .expireAfterWrite(Duration.ofHours(24))
            .recordStats()
            .build());

    return manager;
}

Any number of caches can be registered this way, each completely independent. Spring's documentation is explicit about the boundary: caches that are not registered individually, whether named through setCacheNames or created on demand, still operate with the cache manager's common settings.

Two consequences follow. Every custom cache needs its own recordStats(), because it does not inherit the builder above. And this is per-cache configuration, not per-entry: like every provider behind Spring's abstraction, Caffeine cannot express "cache this particular value for five minutes" through @Cacheable. Genuine per-entry TTL needs a cache API that offers it, which the Valkey and Redis section below covers.

refreshAfterWrite, and Why It Is Awkward Behind @Cacheable

refreshAfterWrite is the most useful Caffeine feature that most Spring Boot applications never turn on. Where expireAfterWrite removes the entry and makes the next caller wait for a reload, refreshAfterWrite keeps serving the old value and reloads in the background. Latency stays flat across the refresh instead of spiking at every expiry.

The catch is that it needs a CacheLoader, which is to say a LoadingCache, and the annotation cannot supply one. What makes this more than an inconvenience is what happens when you supply a loader some other way: Spring's CaffeineCache.lookup calls loadingCache.get(key) whenever the underlying cache is a LoadingCache, so misses are served by the CacheLoader and the body of your annotated method is never invoked. The loading logic moves out of the service and into the cache configuration, which is a larger change than the feature looks like from the outside.

LoadingCache<String, Rate> rates = Caffeine.newBuilder()
        .refreshAfterWrite(Duration.ofMinutes(1))
        .expireAfterWrite(Duration.ofMinutes(10))
        .build(key -> rateClient.fetch(key));

Spring Boot will wire a CacheLoader bean into the auto-configured cache manager for you, but read the constraint before relying on it: it must be declared as CacheLoader<Object, Object>, because the auto-configuration ignores any other generic type, and it is associated with every cache the manager builds. A single loader servicing products, customers and exchangeRates by switching on the key is rarely what anyone wants. The practical route is registerCustomCache with a per-cache loader, or the native LoadingCache above for the handful of caches that need it, accepting in both cases that those caches load through the loader instead of through an annotated method.

Two further behaviours are worth knowing before you enable it. A refresh is only initiated when the entry is queried, so a key nobody reads is never refreshed however stale it gets. And the reload runs on ForkJoinPool.commonPool() unless you pass your own Caffeine.executor(...), which you should if the loader does blocking I/O, because the common pool is shared with everything else in the JVM that uses parallel streams.

Async Cache Mode and CompletableFuture

If your service methods return CompletableFuture, the cache manager can store the futures themselves rather than their resolved values:

@Bean
public CaffeineCacheManager cacheManager() {
    CaffeineCacheManager manager = new CaffeineCacheManager();
    manager.setAsyncCacheMode(true);
    manager.setAllowNullValues(false);
    manager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(1_000)
            .expireAfterWrite(Duration.ofMinutes(10)));
    return manager;
}

Two details from the javadoc are easy to miss. The flag applies to caches named through setCacheNames and to those built on demand, but not to anything passed to registerCustomCache, which stays whatever you built it as. And Spring recommends pairing async mode with setAllowNullValues(false): nulls are tolerated, but disallowing them keeps the CompletableFuture semantics simple and avoids wrapping a handle Caffeine already provides.

Metrics: You Get None Unless You Ask Twice

Caffeine records nothing by default. No hit rate, no miss count, no eviction count, no load times. All of it requires recordStats() on the builder.

The trap that catches people is narrower and worse: caches created through spring.cache.cache-names alone do not record statistics. Micrometer registers cache.size for them regardless, so a dashboard shows something and looks healthy, while the meters that would tell you whether the cache is working are never registered at all. Micrometer does log a warning as it binds — the cache is not recording statistics; no meters except cache.size will be registered — which is the fastest diagnostic available, and also one startup line in a log nobody reads. This was reported against Spring Boot as issue 23047 and closed as invalid: the behaviour is intended, and the fix is on your side.

MeterAppears without recordStats()?What it tells you
cache.sizeYesEntry count. Says nothing about effectiveness
cache.gets (tag result)NoHits and misses — the hit rate
cache.putsNoWrite volume
cache.evictionsNoWhether the cache is thrashing against its bound

Turning the rest on takes two things, and missing either produces the same silence:

spring.cache.cache-names=products,customers,exchangeRates
spring.cache.caffeine.spec=maximumSize=10000,expireAfterWrite=10m,recordStats

recordStats makes the counters exist; spring.cache.cache-names makes the caches exist at startup. The second half is the one people miss. Spring Boot binds caches to the meter registry once, during startup, by walking cacheManager.getCacheNames(), so a cache created lazily on its first @Cacheable call is never instrumented however carefully it was built. For caches that genuinely have to be created later, Spring Boot exposes a CacheMetricsRegistrar bean to register them by hand.

Until those meters exist, you have no idea whether your cache is working. A cache with a five percent hit rate looks exactly like a cache with a ninety-five percent hit rate from the outside.

Problems Teams Actually Hit

Four issues account for most of the trouble beyond configuration.

Null values are not what they appear to be. Caffeine itself does not permit null values. Spring's adapter works around this by wrapping user-level nulls in an internal holder, controlled by setAllowNullValues, which defaults to true. That is usually what you want, since it lets @Cacheable cache a negative lookup instead of hitting the database every time for a key that does not exist. It does mean the object in the cache is not the object you put there, which surprises anyone inspecting the native cache directly.

Weak and soft references silently change equality. weakKeys() switches key comparison to identity, == instead of equals(), so a cache keyed on String or a value class starts missing on every equal-but-not-identical key. weakValues() and softValues() do the same for values: ordinary lookups are unaffected, but asMap().remove(key, value) and asMap().replace(key, old, new) now compare by reference. softValues() carries a second problem of its own, in that it defers your memory limit to the garbage collector, so the cache is trimmed only under pressure, and by then you are already in trouble.

Caching entities rather than data. Caching a JPA entity keeps a detached object with lazy associations alive for as long as the entry lives, and the next caller gets a LazyInitializationException from a proxy that outlived its session. Cache a projection or a DTO instead. If second-level caching is what you are actually after, that is a different mechanism with different rules.

Scheduled eviction that evicts one JVM. A @Scheduled method calling @CacheEvict(allEntries = true) is a common way to force a nightly refresh. On a single instance it works. On three replicas it clears one cache and leaves the other two serving yesterday's data, on whichever pod the scheduler happened to fire, which is the failure the next section is about.

The One Thing Caffeine Cannot Do: A Second Instance

Everything above is configuration. This is architecture, and it is why most teams eventually put something alongside Caffeine.

A Caffeine cache lives in one JVM's heap. It is private to that process. Run two replicas and you have two caches that share nothing and do not know about each other:

  • A write handled by instance A updates A's cache. B keeps serving the old value until its own TTL expires.
  • @CacheEvict evicts locally. The other instances never find out, so cache invalidation stops at the process boundary.
  • Every deploy starts every cache empty, so the database absorbs the full cold-start load on each rollout.
  • Hit rate falls as you scale out, because each instance only benefits from the requests the load balancer happened to route to it.

None of this is a defect in Caffeine. It is what a local cache is, and the distinction is drawn in full under local cache versus distributed cache. The failure mode is that correctness changes silently when you scale from one pod to two: nothing throws, nothing logs, and the staleness window is however long your TTL happens to be. Teams usually meet it as an intermittent bug report nobody can reproduce, because it depends on which replica served the request.

If per-instance staleness bounded by your TTL is acceptable, none of this is a problem, and the section after next says so plainly. If it is not, you need a cache more than one process can see, or a way to tell the other instances that something changed.

The Same Setup on Valkey or Redis

A distributed cache puts one copy where every instance can reach it, and on Valkey or Redis that is Redis caching in the ordinary sense. Redisson is a Java client that implements Spring's CacheManager on top of a Valkey or Redis server, so the swap is a bean definition rather than a rewrite. Every @Cacheable, @CachePut and @CacheEvict annotation, and every call site, stays exactly as it is.

// Caffeine
@Bean
public CacheManager cacheManager() {
    CaffeineCacheManager manager = new CaffeineCacheManager();
    manager.setCaffeine(Caffeine.newBuilder()
            .expireAfterWrite(Duration.ofMinutes(10)));
    return manager;
}

// Redisson — annotations and call sites unchanged
@Bean
public CacheManager cacheManager(RedissonClient redisson) {
    Map<String, CacheConfig> config = new HashMap<>();
    // ttl and maxIdleTime, in milliseconds, per named cache
    config.put("exchangeRates", new CacheConfig(30 * 1000, 0));
    config.put("countries", new CacheConfig(24 * 60 * 60 * 1000, 0));
    return new RedissonSpringCacheManager(redisson, config);
}

Add the redisson-spring-boot-starter and, since Redisson 4.1.0 moved the Spring Cache implementation out of core, the redisson-spring-cache module alongside it. The starter declares that dependency optional, so it is not pulled in transitively and RedissonSpringCacheManager will not resolve without it. That is the change. Note what the second block does for free: per-cache TTL becomes a map entry instead of a registerCustomCache call, expressed in configuration instead of a builder. Per-entry TTL, which Spring's abstraction cannot express for any provider, is available by dropping to RMapCache and calling put(key, value, 10, TimeUnit.MINUTES), without leaving the map API. Values crossing the network do have to be serialised, so the codec you pick becomes a decision that a heap-local cache never forced on you.

There is a second answer, and it is the one to reach for if you liked Caffeine's latency: keep Caffeine, and put an invalidation channel underneath it. That pattern is a near cache, and Redisson ships it as RLocalCachedMap with Caffeine available as the local tier:

// org.redisson.api.options.LocalCachedMapOptions — the cache name is carried in the options
LocalCachedMapOptions<Long, Product> options = LocalCachedMapOptions.<Long, Product>name("products")
        .cacheProvider(LocalCachedMapOptions.CacheProvider.CAFFEINE)
        .cacheSize(1_000)                 // eviction stays Caffeine's own W-TinyLFU
        .timeToLive(Duration.ofMinutes(10))
        .syncStrategy(LocalCachedMapOptions.SyncStrategy.INVALIDATE)
        .reconnectionStrategy(LocalCachedMapOptions.ReconnectionStrategy.CLEAR);

RLocalCachedMap<Long, Product> products = redisson.getLocalCachedMap(options);

Reads that hit the local tier never touch the network, exactly as with a plain Caffeine cache. The difference is syncStrategy(INVALIDATE), the default, which is the channel the previous section was missing; the near-cache page compares it with UPDATE and NONE. Set reconnectionStrategy deliberately too. Its default is NONE, which means a local tier that went deaf during a network blip carries on serving whatever it last saw, and CLEAR is the setting that makes a reconnect safe.

Mind the package on that first line. An older org.redisson.api.LocalCachedMapOptions still exists, entered through defaults(), with a timeToLive that takes milliseconds; it is deprecated, as is the getLocalCachedMap(name, options) overload that goes with it. The current class is org.redisson.api.options.LocalCachedMapOptions, shown above, and mixing the two fails to compile on the Duration argument.

CaffeineRedissonSpringCacheManagerRLocalCachedMap + Caffeine
Works behind @CacheableYesYesPRO only
Per-entry TTL through @CacheableNoNoNo
Per-cache TTLregisterCustomCacheConfiguration mapPer map, local tier
Per-entry TTLVia expireAfter(Expiry)Via RMapCacheVia RLocalCachedMapCache
Correct across instancesNoYesYes, via invalidation
Survives a restartNoWith persistenceWith persistence
Reads avoid the networkYesNoYes, on a local hit

Two licensing points, because they are easy to get backwards. RLocalCachedMap, RMapCache and RedissonSpringCacheManager are all in the Apache-2.0 open-source edition, so the code above needs no licence. Putting a near cache behind @Cacheable, so the annotation model itself gets the local tier, uses RedissonSpringLocalCachedCacheManager, which is part of Redisson PRO along with data partitioning and the clustered cache managers. If you are coming from Spring Data Redis, or moving a codebase onto Spring Boot 4, both paths are documented separately.

When to Keep Caffeine

Often enough, Caffeine on its own is correct and adding anything else is overhead. Keep it unchanged when:

  • You run a single instance, or per-instance staleness of one TTL is acceptable.
  • The data is read-mostly reference data — currency codes, feature definitions, parsed configuration — that changes on a deploy rather than on a request.
  • You are caching something derived and cheap to recompute, so a cold cache after a deploy costs nothing.
  • The latency budget cannot afford a network hop at all, and the working set fits comfortably in heap.

And it is worth saying plainly, because the framing of "Caffeine versus Redis" gets it wrong: they are not alternatives. Caffeine is local and measured in nanoseconds; a distributed cache is shared and measured in microseconds. For most read-heavy services the right answer is both, with an invalidation channel between them, which is precisely what the near cache above is.

Frequently Asked Questions

Which Cache Is Best for Spring Boot?

Caffeine, if you run one instance; a distributed cache the moment you run two. Caffeine is the fastest local cache available on the JVM, but its contents are private to one process. For multiple instances that must agree with each other you need Valkey or Redis, or a data grid such as Hazelcast. For read-heavy services that want both, a near cache combines them: Caffeine in front, a shared store behind, an invalidation channel between.

What Is a Caffeine Cache?

Caffeine is a high-performance in-memory caching library for Java, and the successor to Guava's cache. It stores entries in the heap of a single JVM and evicts them under W-TinyLFU, an admission policy that combines recency and frequency, rather than the plain LRU that Guava used. It supports size- and weight-based bounds, expiry after write or access, asynchronous refresh, and statistics. Spring Boot auto-configures it as a CacheManager when it is on the classpath.

Is Caffeine Cache Good?

Yes — Caffeine is the fastest local cache available on the JVM, and the one Micronaut, Quarkus, Play, Cassandra, Solr, Neo4j and Druid ship with. The important qualifier is what it does not do: it is a local cache, so its contents are private to one process, are lost on restart, and are never told when another instance writes. Judged as a local cache it is excellent. Judged as a distributed cache it is not one.

Is Caffeine Cache L1?

Yes — Caffeine is the standard L1 tier in a two-tier cache. L2 is the shared cache, usually Valkey or Redis. The pattern is only safe with an invalidation channel between the tiers, because otherwise the L1 copies drift from L2 and from each other. Redisson's RLocalCachedMap is that arrangement with the channel already built, and it can use Caffeine for the L1 tier through cacheProvider(CAFFEINE).

What Is the Difference Between Redis and Caffeine Cache?

Caffeine caches inside your application's heap; Redis is a server you connect to over the network. Caffeine answers in nanoseconds with no network hop, but its contents are visible only to that one JVM, disappear on restart, and are never invalidated by writes elsewhere. Redis holds one copy that every instance sees and, if you configure persistence, one that survives a restart, at the cost of a round trip per read. They are complementary rather than competing, and running Caffeine in front of Redis is a well-established pattern — see client-side caching for how the invalidation works.

Next Steps

Caffeine is the right first choice for caching in Spring Boot, and almost every difficulty in this guide came from the abstraction on top rather than the library underneath. All three are fixable in an afternoon: name your caches individually instead of settling for one shared specification, set recordStats and cache-names together so the meters exist, and decide deliberately whether a background refresh is worth moving your loading logic into configuration. What no amount of configuration fixes is the second instance, and it is worth deciding which side of that line you are on before a bug report decides for you.

From here, the Spring Boot caching guide for Valkey and Redis covers the same @Cacheable surface on a distributed cache, Java caching strategies covers cache-aside, read-through and write-behind as patterns rather than annotations, and JCache (JSR-107) covers the portable alternative to Spring's own abstraction. If you arrived here comparing providers, moving from Ehcache to a distributed Java cache and distributed caching in Java cover the neighbouring choices. The Spring Cache reference documents per-cache configuration in full, and the local-cached and clustered cache managers are in Redisson PROtry it for free.

Similar articles