The Quarkus Cache Extension

This section documents Quarkus 3.39.x (current) with 3.33.x LTS as the production recommendation, on the Java 17+ baseline, as published at the official Quarkus guides — which are the reference these pages are written and verified against.

Quarkus 4 raises the baseline to Java 21 (first release candidate planned for end of September 2026, see the Quarkus blog); readers upgrading past 3.39.x/3.33.x LTS should expect that change.

This content was generated with the assistance of AI and should be verified against those official guides before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

quarkus-cache is a method-level, annotation-driven caching abstraction: it caches the return value of a CDI bean method, keyed by (some of) the method’s own arguments. This is a different concern from Hibernate ORM’s second-level cache, covered on the Hibernate ORM and JPA page, which caches loaded entity state inside the persistence layer rather than the result of an arbitrary method call — the two can be used together without conflicting, and neither is a substitute for the other.

Extension setup and annotations

./mvnw quarkus:add-extension -Dextensions=cache

which adds the following to pom.xml:

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-cache</artifactId>
</dependency>

@CacheResult

io.quarkus.cache.CacheResult caches a CDI bean method’s return value in the named cache, keyed by the method’s arguments — a call with arguments already seen returns the cached value without the method body running again:

package com.example.book;

import jakarta.enterprise.context.ApplicationScoped;

import io.quarkus.cache.CacheResult;

@ApplicationScoped
public class BookService {

    @CacheResult(cacheName = "book-by-isbn")
    public Book findByIsbn(String isbn) {
        return BookRepository.blockingFindByIsbn(isbn); // only runs on a cache miss
    }
}

cacheName is required and identifies the cache both in configuration (Redis, and Infinispan) and in the Dev UI (the Dev UI, and caveats); several methods, even across different beans, can share the same cacheName if they should share the same underlying cache and eviction policy.

@CacheInvalidate and @CacheInvalidateAll

io.quarkus.cache.CacheInvalidate removes one entry — keyed the same way @CacheResult would compute the key from the method’s own arguments — and io.quarkus.cache.CacheInvalidateAll clears every entry in the named cache:

package com.example.book;

import jakarta.enterprise.context.ApplicationScoped;

import io.quarkus.cache.CacheInvalidate;
import io.quarkus.cache.CacheInvalidateAll;
import io.quarkus.cache.CacheResult;

@ApplicationScoped
public class BookService {

    @CacheResult(cacheName = "book-by-isbn")
    public Book findByIsbn(String isbn) {
        return BookRepository.blockingFindByIsbn(isbn);
    }

    @CacheInvalidate(cacheName = "book-by-isbn")
    public void update(String isbn, Book book) {
        BookRepository.blockingUpdate(isbn, book);
    }

    @CacheInvalidateAll(cacheName = "book-by-isbn")
    public void reload() {
        BookRepository.blockingReloadCatalog();
    }
}

update above is invalidated by the same isbn argument that findByIsbn uses as its key, so the next lookup for that ISBN recomputes and re-caches it; reload drops the whole cache when the underlying data changes in a way no single key can describe.

@CacheKey and composite keys

When a method takes more than one parameter, every parameter participates in the cache key by default — often wrong, since a parameter like a request-scoped audit context has nothing to do with identity. io.quarkus.cache. CacheKey restricts the key to only the parameter(s) it marks:

package com.example.book;

import jakarta.enterprise.context.ApplicationScoped;

import io.quarkus.cache.CacheKey;
import io.quarkus.cache.CacheResult;

@ApplicationScoped
public class BookService {

    @CacheResult(cacheName = "book-by-isbn")
    public Book findByIsbn(@CacheKey String isbn, RequestAuditContext auditContext) {
        auditContext.record("lookup", isbn);
        return BookRepository.blockingFindByIsbn(isbn); // key is isbn alone
    }
}

Marking more than one parameter @CacheKey combines them into a single composite key, so two calls only hit the same cache entry when all of the marked parameters match:

package com.example.book;

import jakarta.enterprise.context.ApplicationScoped;

import io.quarkus.cache.CacheKey;
import io.quarkus.cache.CacheResult;

@ApplicationScoped
public class BookAvailabilityService {

    @CacheResult(cacheName = "book-availability")
    public boolean isAvailable(@CacheKey String isbn, @CacheKey String warehouseCode, RequestAuditContext auditContext) {
        auditContext.record("availability-check", isbn);
        return BookRepository.blockingIsAvailable(isbn, warehouseCode); // key is (isbn, warehouseCode)
    }
}

A @CacheInvalidate on isAvailable would need the same @CacheKey-marked parameters to compute a matching composite key for eviction — see the invalidation caveat about composite keys under the Dev UI, and caveats.

The programmatic Cache API

Some cases — a key that cannot be expressed from the method’s own parameters, or code outside a CDI-intercepted method — need the underlying cache directly instead of an annotation. io.quarkus.cache.CacheManager looks up a named cache by name, returning io.quarkus.cache.Cache (or its Caffeine-specific subtype, io.quarkus.cache.CaffeineCache, when Caffeine-only operations like size inspection are needed):

package com.example.book;

import java.util.function.Supplier;

import jakarta.enterprise.context.ApplicationScoped;

import io.quarkus.cache.Cache;
import io.quarkus.cache.CacheManager;
import io.quarkus.cache.CacheName;
import io.smallrye.mutiny.Uni;

@ApplicationScoped
public class BookProgrammaticCacheService {

    @CacheName("book-by-isbn")
    Cache cache;

    public Uni<Book> findByIsbnProgrammatically(String isbn) {
        Supplier<Uni<Book>> loader = () -> Uni.createFrom().item(() -> BookRepository.blockingFindByIsbn(isbn));
        return cache.get(isbn, key -> loader.get().subscribeAsCompletionStage());
    }

    public void evict(String isbn) {
        cache.invalidate(isbn);
    }

    public BookProgrammaticCacheService(CacheManager cacheManager) {
        // Equivalent way to obtain the same named cache without field injection.
        this.cache = cacheManager.getCache("book-by-isbn").orElseThrow();
    }
}

Cache.get(key, supplier) returns the cached value if present, otherwise invokes the supplier, stores its result, and returns that; Cache.invalidate(key) and Cache.invalidateAll() mirror @CacheInvalidate and @CacheInvalidateAll for code paths an annotation cannot reach.

Uni support

@CacheResult on a method returning io.smallrye.mutiny.Uni<T> caches the resolved T value once the Uni completes, not the Uni wrapper itself — a second call with the same key gets a new, already-resolved Uni wrapping the cached value, rather than replaying the original asynchronous computation:

package com.example.book;

import jakarta.enterprise.context.ApplicationScoped;

import io.quarkus.cache.CacheResult;
import io.smallrye.mutiny.Uni;

@ApplicationScoped
public class ReactiveBookService {

    @CacheResult(cacheName = "book-by-isbn-reactive")
    public Uni<Book> findByIsbn(String isbn) {
        return Book.findByIsbn(isbn); // Panache reactive repository method, returns Uni<Book>
    }
}

This combines directly with Panache’s reactive return types, covered on the reactive data access page: a reactive repository method already returning Uni<T> can be annotated with @CacheResult exactly as shown above, with no adaptation needed between the two.

Backends: Caffeine, Redis, and Infinispan

Caffeine (default, in-memory, single-instance)

With no further configuration, quarkus-cache uses Caffeine as an in-memory, per-instance cache. Sizing and expiry are configured per named cache:

quarkus.cache.caffeine."book-by-isbn".maximum-size=1000
quarkus.cache.caffeine."book-by-isbn".expire-after-write=10M
quarkus.cache.caffeine."book-by-isbn".expire-after-access=2M

maximum-size bounds the number of entries, evicting least-recently-used entries once the bound is reached; expire-after-write evicts an entry a fixed duration after it was cached regardless of access, while expire-after-access evicts it after a period with no reads — setting both is valid and the tighter of the two wins for any given entry.

Redis

The Redis cache backend shares cached values across every instance of an application, backed by an actual Redis server rather than each instance’s own heap. See the Redis Reference for a deeper look at Redis itself, beyond this Quarkus-specific integration.

./mvnw quarkus:add-extension -Dextensions=cache,redis-client
quarkus.cache.type=redis
quarkus.redis.hosts=redis://localhost:6379

quarkus.cache.redis."book-by-isbn".expire-after-write=10M

quarkus.cache.type=redis switches every @CacheResult/@CacheInvalidate/programmatic-Cache usage in the application onto Redis at once — there is no per-cache-name backend mix. Reach for it when multiple application instances behind a load balancer must all see the same cached value (so a write on one instance is visible to a cache read on another), or when cached state should survive an individual instance restarting, which an in-memory Caffeine cache cannot provide.

Infinispan

The Infinispan cache backend gives the same shared-caching guarantee as Redis, for a deployment already centered on an Infinispan cluster rather than Redis:

./mvnw quarkus:add-extension -Dextensions=cache,infinispan-client
quarkus.cache.type=infinispan
quarkus.infinispan-client.hosts=localhost:11222

quarkus.cache.infinispan."book-by-isbn".expiration.max-idle=2M

As with Redis, quarkus.cache.type=infinispan applies to the whole application; choosing it over Redis is mostly a question of which clustered store the rest of the deployment already operates, since the two provide an equivalent shared-caching guarantee to quarkus-cache callers.

Choosing a backend

Caffeine is the right default for a single instance, or any cache whose misses are cheap enough that every instance recomputing its own copy independently is not a problem. Redis or Infinispan earn their operational cost specifically when multiple instances must observe the same cached value — shared caching across a horizontally-scaled deployment, or cache state that must outlive a single instance’s restart.

Metrics, the Dev UI, and caveats

Micrometer metrics

Once quarkus-micrometer is present on the classpath, quarkus-cache automatically registers cache.gets counters (tagged result=hit/result=miss) per named cache, with no code change — the same Micrometer registry and exporters (Prometheus, OTLP, and so on) used for every other metric in the application pick these up as well.

The Dev UI cache panel

In dev mode, the Dev UI at http://localhost:8080/q/dev/ includes a cache panel listing every named cache discovered on the classpath, its configured backend and settings, and — for Caffeine — its current entries and hit/miss statistics live, without restarting the dev-mode process.

Cache-stampede risk

A cache stampede happens when many concurrent requests miss the same not-yet-cached key at the same instant — without any coordination, every one of them would recompute the same expensive value in parallel. `@CacheResult’s locking behavior mitigates the simple case: concurrent calls for the same key on the same cache block behind the first one, which alone runs the method body, and the rest receive its result once it completes rather than each recomputing it independently. This protects a single cache instance; it does not coordinate across a Redis- or Infinispan-backed cache shared by multiple application instances, where a stampede can still occur across instances even though it cannot occur within one.

Invalidation caveats

Two caveats are easy to overlook:

  • Between a write completing and the matching @CacheInvalidate call actually evicting the entry, a concurrent read can still observe the stale cached value — there is a small window, not an atomic write-then-invalidate.

  • A cache value that is itself collection- or aggregate-shaped (a cached search-results page, a cached list of a book’s reviews) is not automatically invalidated by a write to one of the underlying rows — every affected key needs its own explicit @CacheInvalidate/@CacheInvalidateAll call on write, since quarkus-cache has no way to infer which cached collections a given row change belongs to.

The general multi-level (near/far) caching pattern — an in-process cache in front of a shared distributed one — is discussed on the Spring Boot caching page and the near/far caches page, and is not repeated here; the same pattern applies to a Quarkus application layering Caffeine in front of Redis or Infinispan.