Cache & Hedge

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

The reference material consulted while preparing these pages is listed in the section bibliography.

Two Resilience4j modules sit apart from the five that guard a call against failure (CircuitBreaker, Retry, RateLimiter, Bulkhead, TimeLimiter). Cache avoids the call altogether when a recent answer is stored, and Hedge answers a slow call by asking a second time before the first one has finished. Neither has Spring Boot properties, annotations, Actuator endpoints or Micrometer meters, so both are used programmatically, typically as plain beans in a configuration class. This page covers both end to end. It was written against Resilience4j 2.4.0, Spring Boot 4.1.x, Spring Framework 7.0.x and Java 21, and every example on it was compiled and run on those versions on 4 October 2026 (the Cache examples with the Caffeine JCache provider 3.2.4, the Hedge examples against a local WireMock server). Both modules are independent of the Spring Boot starter, so nothing in them differs between Boot 4 and Boot 3.5.x. The programming model they share with the other modules (decorators, registries, event publishers) is in Registries, Configuration & Decorators; how a cache or a hedge fits with the other patterns is in Fallbacks & Composition; the vocabulary (idempotency, tail latency tradeoffs) is in Resilience Fundamentals.

In the running Bookshelf scenario, the third-party ISBN-metadata API has a quota and a slow tail, and its answers (a title for an ISBN) hardly ever change, which makes it the ideal candidate for a cache. The pricing-service is read-only for the catalogue page and occasionally answers from a slow replica, which makes it the candidate for a hedge.

Part 1: the Cache module

What it is, and what it is not

resilience4j-cache wraps a JCache (JSR-107, package javax.cache) Cache in a Resilience4j Cache<K, V> that does one thing: a read-through lookup. Given a key and a function that produces the value, it returns the cached value if there is one, and otherwise calls the function, stores the result and returns it. It adds three things to the JCache API: hit, miss and error events, a hit/miss counter, and the decorator shape that composes with the other modules.

It is not a cache. It stores nothing itself, has no size limit, no expiry, no eviction and no serialisation of its own: all of that comes from the JCache provider you put on the classpath. The module depends only on resilience4j-core and the JCache API (javax.cache:cache-api; the 2.4.0 build uses 1.1.0 and Spring Boot 4.1.1 resolved 1.1.1 in the sample project). The JCache API (1.1.x) still lives in the javax.cache package, not jakarta.cache, and that is the package every provider in this page uses.

flowchart TD A([apply key]) --> B[javax.cache.Cache.get key] B -->|value present| H[publish CacheOnHitEvent, hits + 1] H --> R1([return the cached value]) B -->|null| M[publish CacheOnMissEvent, misses + 1] B -->|exception| E1[publish CacheOnErrorEvent, no hit or miss counted] M --> I[cache.invoke key with an entry processor] E1 --> I I -->|entry absent| S[run the supplier, store the result] I -->|entry present| R2([return the stored value]) S --> R3([return the new value]) I -->|exception| E2[publish CacheOnErrorEvent] E2 --> F[run the supplier again, outside the cache] F --> R4([return the value, or throw RuntimeException])

The flow in the diagram is the whole module, CacheImpl in the source. The sections below walk through its consequences.

Dependencies

resilience4j-cache is in the resilience4j-bom and is a dependency of resilience4j-all (the module that provides Decorators), so with resilience4j-all you already have it. The resilience4j-spring-boot4 starter does not bring it. A JCache provider is separate and is your choice; the example below uses the Caffeine one.

Maven: the cache module and a production JCache provider
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>io.github.resilience4j</groupId>
      <artifactId>resilience4j-bom</artifactId>
      <version>2.4.0</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-cache</artifactId>
  </dependency>
  <!-- a JCache provider; Caffeine's is one of several (see "Choosing a provider") -->
  <dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>jcache</artifactId>
    <version>3.2.4</version>
  </dependency>
</dependencies>

Source: Resilience4j: Cache and the resilience4j-cache module at v2.4.0. The Caffeine artifact is documented in the Caffeine JCache wiki page.

With exactly one JCache provider on the classpath, Caching.getCachingProvider() finds it through the ServiceLoader (the test printed CaffeineCachingProvider). With more than one, name the provider explicitly or the call fails.

Cache.of and the decorators

Cache.of(javax.cache.Cache) wraps an existing JCache instance. The static decorateSupplier, decorateCallable and decorateCheckedSupplier helpers turn a supplier into a function of the cache key, which is the one place the Cache differs from the other modules, whose decorators keep the shape of the decorated function.

Wrapping a JCache, reading through it and listening to the events
CacheManager manager = Caching.getCachingProvider().getCacheManager();
javax.cache.Cache<String, String> jcache = manager.createCache("isbn-titles",
    new MutableConfiguration<String, String>().setTypes(String.class, String.class));

Cache<String, String> cache = Cache.of(jcache);
cache.getEventPublisher()
    .onCacheHit(e -> log.info("HIT {}", e.getCacheKey()))
    .onCacheMiss(e -> log.info("MISS {}", e.getCacheKey()))
    .onError(e -> log.warn("cache error {}", e.getThrowable().toString()));

AtomicInteger backend = new AtomicInteger();
Function<String, String> title = Cache.decorateSupplier(cache,
    () -> "Title #" + backend.incrementAndGet());

title.apply("978-0");   // MISS, backend call
title.apply("978-0");   // HIT
title.apply("978-1");   // MISS, backend call
// cache.getMetrics(): hits = 1, misses = 2; the backend ran twice

Source: Resilience4j: Cache, adapted. The readme.io page shows Vavr’s CheckedFunction1 and Try; since Resilience4j 2.0.0 Vavr is gone from the core and the checked function type is io.github.resilience4j.core.functions.CheckedFunction. Run: the events were [MISS 978-0, HIT 978-0, MISS 978-1], and getName() returned the JCache name isbn-titles.

Decorators (from resilience4j-all) adds withCache(cache) to the Supplier and CheckedSupplier builders. It returns a DecorateFunction (a DecorateCheckedFunction for the checked builder), because it turns the chain into a function of the key, so it is the last decoration of a chain:

Decorators.withCache: the cache sits in front of the breaker
CheckedFunction<String, String> cached = Decorators
    .ofCheckedSupplier(() -> remoteTitle())
    .withCircuitBreaker(circuitBreaker)
    .withCache(cache)
    .decorate();

cached.apply("978-0");   // miss: the breaker guards the remote call
cached.apply("978-0");   // hit: the breaker never sees it (1 successful call recorded)

Source: Resilience4j: Cache and Decorators at v2.4.0. Run: the breaker recorded exactly one successful call after two lookups.

withCache is defined only for Supplier and CheckedSupplier decorations, not for CompletionStage, Callable, Runnable or reactive types, and the Kotlin and Reactor modules have no cache operator. For a reactive pipeline, wrap the blocking lookup yourself or use a reactive cache library.

Where the cache goes in a chain

Decorators applies calls in the order written, the last one outermost, and withCache produces a function, so it is always the outermost link. That is also the right place: a hit then costs a map lookup and does not consume a retry attempt, a breaker permit, a rate-limiter permit or a bulkhead slot, and does not pollute their metrics with calls that never reached the backend. Within the miss path the order is the usual one from Fallbacks & Composition: Retry outside CircuitBreaker.

The Bookshelf IsbnTitles service puts all of it together, in a Spring Boot application:

Bookshelf: a cached ISBN lookup, with Retry and CircuitBreaker on the miss path
@Configuration(proxyBeanMethods = false)
class IsbnCacheConfig {

    @Bean
    Cache<String, String> isbnTitleCache(javax.cache.CacheManager cacheManager) {
        javax.cache.Cache<String, String> jcache = cacheManager.createCache("isbn-titles",
            new MutableConfiguration<String, String>()
                .setTypes(String.class, String.class)
                .setExpiryPolicyFactory(CreatedExpiryPolicy.factoryOf(Duration.ONE_HOUR)));
        return Cache.of(jcache);
    }
}

@Service
public class IsbnTitles {

    private final IsbnMetadataClient client;
    private final Cache<String, String> cache;
    private final CircuitBreaker breaker;
    private final Retry retry;

    IsbnTitles(IsbnMetadataClient client, Cache<String, String> isbnTitleCache,
               CircuitBreakerRegistry breakers, RetryRegistry retries) {
        this.client = client;
        this.cache = isbnTitleCache;
        this.breaker = breakers.circuitBreaker("isbn");
        this.retry = retries.retry("isbn");
    }

    public String title(String isbn) {
        Supplier<String> remote = Decorators.ofSupplier(() -> client.title(isbn))
            .withCircuitBreaker(breaker)
            .withRetry(retry)
            .decorate();
        // the cache is outermost: a hit never reaches the retry, the breaker or the network
        return Cache.decorateSupplier(cache, remote).apply(isbn);
    }
}

Source: Resilience4j: Cache and the Spring Boot caching reference (for the auto-configured javax.cache.CacheManager). Run: three calls for the same ISBN produced one request to the stubbed API, two cache hits, one miss and one successful call on the breaker. The Spring Boot javax.cache.CacheManager bean used above comes from the JCache auto-configuration, which needs spring-boot-starter-cache, @EnableCaching and a provider on the classpath; without them, create the manager yourself with Caching.getCachingProvider().getCacheManager().

Events and metrics

The Cache publishes three events through getEventPublisher(), and keeps two counters. Event consumers run on the calling thread, and the event objects are created only when at least one consumer is registered.

Event class getEventType() Published when

CacheOnHitEvent

CACHE_HIT

javax.cache.Cache.get returned a value. Carries getCacheName() and getCacheKey().

CacheOnMissEvent

CACHE_MISS

get returned null. Carries the cache name and key.

CacheOnErrorEvent

ERROR

get or the store step threw. Carries getThrowable() but no key.

getMetrics() returns getNumberOfCacheHits() and getNumberOfCacheMisses(), two LongAdder counters that only grow. There is no Micrometer binder, no Actuator endpoint and no health indicator for the Cache module in 2.4.0, so to chart a hit ratio, register the two counters as gauges yourself, or use the provider’s own statistics (JCache has a statisticsEnabled configuration flag and an MXBean). A cache that fails on every call shows up as growing error events and, importantly, as no growth in either counter.

What happens when the cache fails

The module’s central promise, stated on the readme.io page, is that a failing cache does not fail the call: when a lookup from a distributed cache fails, the exception is absorbed and the function is called. The source and a run with a JCache wrapper that throws show exactly what that means for each way a cache can fail.

Failure Observed behaviour Events and counters

get throws (cache down)

The exception is logged at WARN and absorbed; the call goes to the invoke step, which may find or store the value. With a cache that is entirely down, every call reaches the backend.

One CacheOnErrorEvent; no hit, no miss.

invoke (the store step) throws, but the supplier is fine

The error is logged and absorbed, and the supplier runs again, outside the cache, so its value is returned.

CacheOnMissEvent then CacheOnErrorEvent.

The supplier throws

The exception reaches invoke, which wraps it in EntryProcessorException; the module treats that as a cache error, runs the supplier a second time, and if that throws too, rethrows it wrapped in a plain RuntimeException. In the run the backend was called twice for one failing lookup and the caller received a RuntimeException whose cause was the original IllegalStateException.

CacheOnMissEvent then CacheOnErrorEvent (carrying EntryProcessorException).

The supplier returns null

JCache forbids null values: the store throws NullPointerException, the supplier runs a second time, and the second null is returned. A null result is therefore never cached and always costs two backend calls.

CacheOnMissEvent then CacheOnErrorEvent.

The value cannot be stored, for example because it is not Serializable with a store-by-value cache

See the next section: with Caffeine the lookup never hits, so every call reaches the backend.

Error events; hit counter stays at zero.

Run: with a Bookshelf backend that answered 500 to every request and Retry configured for two attempts, one IsbnTitles.title call produced four requests to the backend (two attempts, twice) before the caller saw an exception. That doubling comes from the Cache module re-running the supplier, not from Retry.

Two practical conclusions follow. A cache lookup that fails quickly is harmless; one that fails slowly (a Redis connection that hangs for its socket timeout) adds that delay to every call, so give the provider’s client a short timeout, or put a CircuitBreaker around the cache itself, as sketched in Near and Far Caches. And because a failing supplier may run twice, the decorated function must be safe to call twice, which is the same idempotency rule Retry has.

Choosing a provider

The readme.io page says that the JCache reference implementation (RI) should not be used in production, because it has concurrency problems, and recommends Ehcache, Caffeine, Redisson, Hazelcast, Ignite or another provider. The RI is the org.jsr107.ri:cache-ri-impl artifact; keep it out of production code, and use a real provider even in integration tests when you want them to resemble production.

Provider Kind Notes

Caffeine JCache (com.github.ben-manes.caffeine:jcache)

In-process, very fast

The one used and run on this page. Same engine as Spring’s Caffeine support; expiry, size and statistics through the JCache configuration (or the provider’s own configuration file, see the Caffeine wiki).

Ehcache 3 (org.ehcache:ehcache)

In-process, with disk and clustered tiers

Implements JSR-107 (see the Ehcache JSR-107 documentation); the readme.io page has an Ehcache example. Not run here.

Hazelcast, Apache Ignite, Redisson (Redis)

Distributed

JCache providers for a shared cache across instances; each needs its own client configuration, serialisation and timeouts. Named on the readme.io page. Not run here.

Choosing a provider is the same decision as choosing a near or far cache in Near and Far Caches; the distributed ones add a network call that can fail or hang, which is exactly the case the failure rules above and a short client timeout are about.

JCache configuration that matters for correctness

The Cache module delegates all of it, so the JCache MutableConfiguration is where the real settings are:

  • Store by value is the JCache default. new MutableConfiguration<>().isStoreByValue() is true, so values are copied through serialisation, and with Caffeine a value that is not java.io.Serializable is never stored. Run: a cache of a plain record returned no hit on two lookups and published error events, because the backend ran on both; the same record declared implements Serializable hit on the second lookup, and with setStoreByValue(false) it hit as well, returning the stored instance itself (so callers must treat cached values as immutable).

  • Expiry is the time to live you configure. CreatedExpiryPolicy.factoryOf(new Duration(MILLISECONDS, 150)) made the third lookup, 400 ms later, a miss. With no expiry policy entries live until evicted, so a stale ISBN title is served forever; set a policy for anything that can change.

  • The key is whatever you pass to apply. Choose a stable, normalised key (the ISBN without hyphens, say) and include every input of the supplier in it, or two different calls will share an entry.

  • The cache name belongs to the manager. createCache on an existing name throws javax.cache.CacheException (run); look it up with getCache when the manager outlives your bean, for example across context refreshes in tests.

Store by value, expiry and a provider-neutral configuration
MutableConfiguration<String, SerializablePrice> config = new MutableConfiguration<String, SerializablePrice>()
    .setTypes(String.class, SerializablePrice.class)
    .setExpiryPolicyFactory(CreatedExpiryPolicy.factoryOf(
        new javax.cache.expiry.Duration(TimeUnit.MINUTES, 10)))
    .setStatisticsEnabled(true);

record SerializablePrice(String isbn, long cents) implements java.io.Serializable { }

Source: the JSR-107 specification page and its API repository. Run: the same configuration style with a 150 ms CreatedExpiryPolicy expired an entry, and the three variants of the price record behaved as described above.

No Spring Boot properties

There is no resilience4j.cache prefix. The 2.4.0 source of resilience4j-spring6, resilience4j-framework-common and resilience4j-spring-boot4 has no cache configuration class, no @Cache annotation, no auto-configured Cache bean and no Actuator endpoint for it. The only Spring Boot properties that touch a JCache are Spring’s own: with spring-boot-starter-cache, @EnableCaching and spring.cache.type=jcache, Boot builds a javax.cache.CacheManager and a JCacheCacheManager, and spring.cache.jcache.provider and spring.cache.jcache.config select the provider and its configuration resource. Those properties configure Spring’s cache abstraction, not Resilience4j; the isbnTitleCache bean above simply reuses the same manager, and the printed cache names were [book-prices, isbn-titles], one created by Boot from spring.cache.cache-names and one by the bean.

Relation to Spring’s @Cacheable

The two solve the same problem at different levels, and can coexist on the same JCache provider and manager.

Aspect Resilience4j Cache Spring @Cacheable

Mechanism

A decorator you apply to a function in code

An annotation on a bean method, applied by a proxy (so self-invocation bypasses it)

Cache key

The argument of apply(key)

Derived from the method arguments, or a SpEL key expression

Backing stores

JCache (JSR-107) only

Any CacheManager: Caffeine, Redis, JCache, ConcurrentMap

Behaviour when the cache fails

Absorbs the error, publishes CacheOnErrorEvent, calls the supplier

By default the exception propagates, unless a CacheErrorHandler is configured

Conditions, eviction, put

None: only read-through

condition, unless, @CachePut, @CacheEvict

Composition with resilience patterns

Explicit, in code: the cache is the outermost link

Order between @Cacheable and the Resilience4j aspects depends on aspect ordering, as in Annotations & Aspect Order

Rule of thumb: use @Cacheable for ordinary application caching and Spring’s rich cache features, described in Caching: Spring Cache, Caffeine and Redis; use the Resilience4j Cache when you specifically need the cache failure to be absorbed and observed in the same decorator chain as a breaker and a retry, which is typical for a call to a remote, quota-limited API. Run: a @Cacheable bean on the cache book-prices loaded once for two calls, on the same Boot context in which the Resilience4j-decorated ISBN lookup ran.

CacheRegistryStore: a cache as the registry’s store

CacheRegistryStore<E> implements the RegistryStore extension point (see Registries, Configuration & Decorators) on top of a JCache Cache<String, E>, with computeIfAbsent, find, remove, replace and values mapped to the JCache operations. It lets a registry keep its entries in a cache instead of a ConcurrentHashMap. The caveats are significant: registry entries (a CircuitBreaker, say) are live objects with state and threads, they are not Serializable, so the JCache must be configured with setStoreByValue(false) (with the default it fails with UncheckedIOException: Failed to serialize class …​CircuitBreakerStateMachine, as the run showed), and a distributed JCache cannot share the objects between JVMs anyway. In practice this is only useful for a local cache with custom eviction.

A registry backed by a by-reference JCache
javax.cache.Cache<String, CircuitBreaker> store = manager.createCache("cb-store",
    new MutableConfiguration<String, CircuitBreaker>().setStoreByValue(false));

CircuitBreakerRegistry registry = CircuitBreakerRegistry.custom()
    .withCircuitBreakerConfig(CircuitBreakerConfig.ofDefaults())
    .withRegistryStore(new CacheRegistryStore<>(store))
    .build();

Cache pitfalls

Pitfall What to do

Using the JCache reference implementation in production

Use Caffeine, Ehcache or a distributed provider.

Default store-by-value with non-serialisable values

Make the value Serializable, or set setStoreByValue(false) for an in-process cache and treat values as immutable.

No expiry policy

Set a CreatedExpiryPolicy (or the provider’s equivalent); the module has no time to live of its own.

The supplier running twice when it throws or returns null

Make it idempotent; return an empty object, not null; remember that Retry counts add to this.

A slow distributed cache

The error path is only for exceptions. Use short client timeouts or guard the cache with a breaker.

Caching failures or fallback values

Only successful results are stored. Do not decorate the fallback with the cache, or a degraded value is cached as if it were real.

Expecting metrics in Actuator or Prometheus

There are none; read getMetrics() or the provider’s statistics.

Keys that omit an input of the supplier

Build the key from every parameter that changes the result.

Part 2: the Hedge module

What a hedged request is

Tail latency is the slowest few percent of calls. A service that answers in 20 ms nearly always, but in 800 ms one call in a hundred, makes a page that calls it ten times slow for one user in ten. Retry and TimeLimiter react to failure or to a timeout, both of which mean waiting for the long tail. A hedged request attacks it differently: if the first call has not answered after a short delay, send a second, identical call (to another replica, if the load balancer rotates), and use whichever answers first. The slow call is usually the one that hit a busy instance, and the second one rarely does.

The price is extra load: every hedge is a real request to the backend, so a hedge delay that is too short multiplies traffic. And because the same request is sent twice, hedging suits only idempotent calls: reads, and writes that carry an idempotency key. A hedged POST /orders without an idempotency key can create two orders. See Resilience Fundamentals for idempotency as the precondition for any repetition of a call.

resilience4j-hedge (copyright headers of 2021) implements this. There is no readme.io page for it; this section is documented from the v2.4.0 source and its tests, and every claim was run.

sequenceDiagram participant C as Caller participant H as Hedge participant P as Primary attempt participant S as Secondary attempt C->>H: submit(callable) H->>P: start the first call Note over H: wait for the hedge delay, for example 100 ms H->>S: start the same call again S-->>H: answers at 120 ms H-->>C: return the secondary result Note over P: not interrupted, still running P-->>H: answers at 600 ms, event recorded, result discarded

Setup: it is outside the BOM

resilience4j-hedge is published to Maven Central as 2.4.0 like the other modules, but it is not in resilience4j-bom and not a dependency of resilience4j-all. Declare it with an explicit version, like resilience4j-spring-boot4:

Maven: hedge needs an explicit version
<dependency>
  <groupId>io.github.resilience4j</groupId>
  <artifactId>resilience4j-hedge</artifactId>
  <version>${resilience4j.version}</version>
</dependency>

Source: resilience4j-hedge at v2.4.0. Run: the sample project resolved it with the explicit version, and the module depends only on resilience4j-core. The module is also absent from every other module of the build: Decorators has no withHedge, and there is no Spring, Micrometer, Reactor or Kotlin integration for it. The only mentions of hedge in the 2.4.0 source tree outside the module are in settings.gradle.

The API: Hedge, HedgeConfig, HedgeRegistry

Hedge follows the pattern of the other modules: Hedge.ofDefaults(), Hedge.ofDefaults(name), Hedge.of(config), Hedge.of(name, config), Hedge.of(name, config, tags) and the shortcut Hedge.of(Duration), which creates a hedge with a fixed delay. HedgeRegistry (HedgeRegistry.builder()…​build()) is an in-memory registry with hedge(name), hedge(name, config), hedge(name, configName) and the other usual overloads, shared configurations through addConfiguration, and the same semantics as the other registries: asking twice for the same name returns the same instance. The name defaults to UNDEFINED.

The execution methods are two:

  • submit(Callable<T>, ExecutorService) returns a CompletableFuture<T>. The primary call runs on the executor you pass, and the hedged call runs on the hedge’s own scheduled executor.

  • decorateCompletionStage(Supplier<CompletionStage<T>>) returns a supplier of the same shape. Both the primary and the hedged call use the same supplier, so it must start a new asynchronous operation each time it is called.

There is no blocking Supplier decoration: hedging needs asynchronous results, so a synchronous call is wrapped in a Callable for submit.

HedgeConfig.Builder method Default Meaning

preconfiguredDuration(Duration)

PRECONFIGURED, no delay set (null)

Fixed hedge delay. Selects the PRECONFIGURED supplier. A null argument throws NullPointerException.

averagePlusAmountDuration(int amount, boolean shouldMeasureErrors, int windowSize)

-

Selects AVERAGE_PLUS: the delay is the average of the last windowSize primary durations plus amount milliseconds.

averagePlusPercentageDuration(int percent, boolean shouldMeasureErrors)

-

Selects AVERAGE_PLUS with a percentage: the delay is the average multiplied by percent / 100. The window stays at its default.

withMaxConcurrency(int)

10

Core size of the hedge’s own scheduled thread pool (ContextAwareScheduledThreadPoolExecutor).

withContextPropagators(…​)

none

ContextPropagator classes or instances, so MDC and similar context reach the hedge threads.

(internal) window size

100

Size of the sliding window of the average supplier (getWindowSize()).

(internal) measure errors

true

Whether failed primary calls count towards the average (isShouldMeasureErrors()).

The Hedge.of(Duration) shortcut and HedgeConfig.custom().preconfiguredDuration(…​) are the same thing. Run: the default HedgeConfig.ofDefaults() printed PRECONFIGURED, a null cutoff, factor 0, window 100, measure-errors true and concurrency 10.

There are no Spring Boot properties for hedging: no resilience4j.hedge prefix, no HedgeConfigCustomizer, no auto-configured HedgeRegistry. Create the registry as a bean yourself, or the hedge directly.

The two duration suppliers

  • PRECONFIGURED waits a fixed time. Choose it close to the latency percentile you want to cut: if the p95 of the pricing call is 120 ms, a delay of about 120 ms hedges roughly 5% of the requests, which is only 5% extra load.

  • AVERAGE_PLUS recomputes the delay from observed primary call durations (successes, and failures when shouldMeasureErrors is true). Run: an empty window gave a delay of PT0.02S for averagePlusAmountDuration(20, true, 10), and after five calls of about 40 ms the delay settled around 69 ms. The percentage variant is not "average plus percent": the source computes average * percent / 100, so averagePlusPercentageDuration(150, true) is 1.5 times the average (72 ms in the run) and 50 would hedge at half the average, which would hedge most calls.

A delay close to the average hedges about half of the calls, because by definition half are slower than the average. That is rarely what you want; prefer a fixed delay near a high percentile, or an AVERAGE_PLUS amount that is a multiple of the typical latency.

Semantics, from the source

HedgeImpl.decorateCaller is short, and its behaviour is worth stating precisely, because it differs from what the name suggests:

  • The primary call starts at once. A scheduled task completes a trigger after the hedge delay, and only then starts the hedged call.

  • The first call to complete wins, whether it succeeded or failed. If the primary fails before the delay, its exception is returned and no hedge is ever started (run: one attempt, a CompletionException). If the hedged call fails while the primary is still running and would have succeeded, the hedge’s failure is what the caller gets (run: secondary failure counted, CompletionException thrown, while a 300 ms primary was still pending). Hedging does not turn errors into successes; for that, put a Retry or a fallback around it.

  • When the primary wins, the scheduled trigger is cancelled and the hedged future cancelled. When the hedge wins, the primary future is cancelled with cancel(false): the primary call is not interrupted and keeps running to the end. In the Bookshelf run the slow pricing-service request was still served after the caller had its answer, and the circuit breaker recorded both attempts. The loser’s work is wasted, not cancelled.

  • Failures are rethrown as (RuntimeException) throwable. With submit, the callable’s exception is already wrapped in a RuntimeException, and the caller sees CompletionException with that RuntimeException as the cause. With decorateCompletionStage and a future that fails with a checked exception (a CompletableFuture.failedFuture(new IOException(…​))), the cast fails: the caller receives an ExecutionException whose cause is a ClassCastException, not the IOException. Make decorated futures fail with unchecked exceptions.

  • Hedge.ofDefaults() has no delay. Using it fails with a NullPointerException on Duration.toNanos() the first time a call is made (run). Always set a duration.

Events and metrics

The hedge records the outcome of whichever call wins, in Hedge.Metrics and through Hedge.EventPublisher.

Event class getEventType() Published when

HedgeOnPrimarySuccessEvent

PRIMARY_SUCCESS

The primary call won and succeeded. Carries getHedgeName(), getDuration() and getCreationTime().

HedgeOnPrimaryFailureEvent

PRIMARY_FAILURE

The primary call won with an exception; also carries getThrowable().

HedgeOnSecondarySuccessEvent

SECONDARY_SUCCESS

The hedged call won and succeeded.

HedgeOnSecondaryFailureEvent

SECONDARY_FAILURE

The hedged call won with an exception; also carries getThrowable().

Consumers are registered with onPrimarySuccess, onPrimaryFailure, onSecondarySuccess and onSecondaryFailure. getMetrics() exposes getCurrentHedgeDelay(), getPrimarySuccessCount(), getSecondarySuccessCount(), getPrimaryFailureCount(), getSecondaryFailureCount() and getSecondaryPoolActiveCount(). The secondary success ratio is the number to watch: a ratio near zero says the delay is too long to matter, a ratio near one says the delay is below the normal latency and every call is being doubled. As with Cache, nothing is bridged to Micrometer or Actuator.

Running a hedge in the Bookshelf

The catalogue page reads the price of a book from the pricing-service, an idempotent GET. The test stubs a replica that is slow (600 ms) for the first request and fast (20 ms) afterwards, and hedges after 100 ms. The circuit breaker is placed inside the hedge, so it sees every attempt, the hedged one included.

Bookshelf: a hedged read of an idempotent endpoint
public class HedgedPricing implements AutoCloseable {

    private final RestClient http;
    private final Hedge hedge;
    private final CircuitBreaker breaker;
    private final ExecutorService primary = Executors.newVirtualThreadPerTaskExecutor();

    public HedgedPricing(RestClient.Builder builder, String baseUrl, Hedge hedge, CircuitBreaker breaker) {
        this.http = builder.baseUrl(baseUrl).build();
        this.hedge = hedge;
        this.breaker = breaker;
    }

    public Money price(String isbn) {
        Callable<Money> attempt = CircuitBreaker.decorateCallable(breaker,
            () -> http.get().uri("/prices/{isbn}", isbn).retrieve().body(Money.class));
        return hedge.submit(attempt, primary).join();
    }

    @Override
    public void close() {
        primary.shutdown();
    }
}

Hedge hedge = Hedge.of("pricing", HedgeConfig.custom()
    .preconfiguredDuration(Duration.ofMillis(100))
    .withMaxConcurrency(4)
    .build());

Source: derived from the v2.4.0 resilience4j-hedge source and tests, there being no official documentation page. Run: after a warm-up call, price("978-0") returned in under 400 ms instead of the 600 ms the slow replica took, with two requests to the stub, secondarySuccess 1 and primarySuccess 0; 800 ms later the breaker had recorded two successful calls, the hedged one and the slow primary that finished in the background.

Composing a hedge with the other modules

Since Decorators has no hedge, composition is manual, and the position decides what each module measures.

  • Retry outside the hedge. Each retry attempt is a hedged call. This is the sensible pairing: the hedge cuts the latency of each attempt, and Retry recovers from failures, which the hedge does not.

  • CircuitBreaker, Bulkhead and RateLimiter inside the hedge (as in the example) count every real request, which is what protects the backend from the extra load. Outside the hedge they would count only the logical call and the hedge could exceed their limits.

  • TimeLimiter. Put a hard timeout on the whole hedged call (outside), and do not rely on the hedge to bound latency: it does not cancel the loser and it does not time out.

  • Cache in front. A cache in front of a hedge makes the hedge rarer, because only misses reach it.

The detailed rules for ordering are in Fallbacks & Composition.

Hedge pitfalls

Pitfall What to do

Hedging a non-idempotent call

Do not, unless the operation carries an idempotency key the server honours.

Hedge.ofDefaults() or no preconfiguredDuration

There is no default delay: calls throw NullPointerException. Always configure a duration.

averagePlusPercentageDuration(50, …​) thought of as "average plus 50%"

It is average * 50 / 100, half the average. Use 150 for 1.5 times.

HedgeConfig.from(base) of an AVERAGE_PLUS config

The builder copies the factor, window and cutoff but not the supplier type, so the copy reverts to PRECONFIGURED with a null cutoff (run: AVERAGE_PLUS became PRECONFIGURED, getCutoff() was null). Rebuild the config explicitly.

A delay near the average latency

About half the calls are doubled. Aim for a high percentile.

Failing futures with checked exceptions

The (RuntimeException) cast fails with ClassCastException. Use unchecked exceptions.

The loser keeps running

The primary is not interrupted; make the backend call cheap to abandon, and size the backend for the extra load.

Non-daemon threads per hedge instance

Every Hedge creates its own scheduled pool (ContextAwareScheduledThreadPool-N, size withMaxConcurrency) with non-daemon threads (isDaemon() was false in the run) and the API has no close: create hedges once, as registry-managed singletons, never per request, and do not expect a plain main to exit while one has scheduled work.

Expecting hedge metrics in Actuator or Prometheus

There are none; export getMetrics() yourself.

What changed recently

  • 2.0.0 removed Vavr from the core (the readme.io Cache example still uses CheckedFunction1 and Try).

  • 2.4.0 did not change the Cache or Hedge modules; the release notes are in the GitHub release (RELEASENOTES.adoc at the tag stops at 2.3.0).

  • The readme.io Cache page is from version 0.x and recommends compile 'org.ehcache:ehcache:3.7.1', a Gradle syntax Gradle removed; use implementation. The resilience4j-hedge module has no readme.io page at all and is absent from the BOM, from resilience4j-all and from the readme.io module list.