For a Spring Boot 3.4 codebase, Resilience4j 2.3 is the ergonomic default: one annotation plus a YAML block replaces about forty lines of Failsafe wiring, and the actuator and Micrometer bindings come free. Failsafe 3.3 wins only when you are operating outside Spring’s bean lifecycle — CLI tools, library code, or a hot path where you refuse a CGLIB proxy frame — because it gives you explicit policy ordering and zero AOP gotchas.
Quick nav
- The 70-word answer: when Resilience4j wins, when Failsafe wins, in Spring Boot 3.4
- The same OrderService, two libraries, side by side
- The Spring AOP trap that silently disables @CircuitBreaker
- Composition order: decorators vs. Policy<T> nesting
- Half-open semantics are not the same in both libraries
- Observability: Micrometer binding present vs. absent
- Overhead on the hot path: where the Spring proxy frame actually costs you
- Decision rubric: five questions that pick the library for you
- References
- Library pairing: the
resilience4j-spring-boot3starter on a Spring Boot 3.4 app, contrasted with Failsafe used directly via itsFailsafeExecutorAPI. - Wiring delta: a single
@CircuitBreaker(name="orders", fallbackMethod="fallback")plus eight YAML keys vs. a builder-drivenFailsafeExecutor<Order>with explicit policy chaining. - Silent failure mode: Resilience4j’s annotation is AOP-driven, so
this.protectedCall()from another method on the same bean never engages the breaker — actuator staysstate: CLOSEDunder 100% failures. - Observability gap: Resilience4j auto-binds to Micrometer and exposes
/actuator/circuitbreakers; Failsafe ships neither and requires manual instrumentation. - Composition rule: in Failsafe,
Failsafe.with(fallback, retry, circuitBreaker)nests the last argument innermost — the leftmost policy wraps the rest.
The 70-word answer: when Resilience4j wins, when Failsafe wins, in Spring Boot 3.4
Pick Resilience4j if your protected calls live inside Spring beans, you want /actuator/circuitbreakers, and you want a Micrometer time series under resilience4j.circuitbreaker.* with no glue code. Pick Failsafe if you are writing a library, a CLI, or a hot path where you cannot tolerate a proxy frame, or if you want the policy stack to be a literal Failsafe.with(...) argument list rather than a set of stacked annotations.
Purpose-built diagram for this article — Resilience4j 2.3 vs Failsafe 3.3: circuit breaker ergonomics in Spring Boot 3.4.
moving off reactive stacks goes into the specifics of this.
The diagram above maps a single inbound request through both libraries: on the Resilience4j side the request hits a CGLIB-generated proxy that delegates to CircuitBreakerAspect before the real bean method runs; on the Failsafe side the same call goes through a plain FailsafeExecutor that wraps a Supplier<T> with no Spring infrastructure in the path. That structural difference is where every other tradeoff in this article — observability, ergonomics, hot-path cost, AOP traps — actually lives.
The same OrderService, two libraries, side by side
The honest way to compare ergonomics is to write the same service twice. Below is OrderService.placeOrder() protected by Resilience4j on the left and Failsafe on the right. The protected call is a remote HTTP request to a payments gateway; the fallback returns a queued Order with a PENDING status.
The Resilience4j version is one annotation plus a fallback method:
See also command-side architecture.
@Service
public class OrderService {
private final PaymentClient payments;
public OrderService(PaymentClient payments) {
this.payments = payments;
}
@CircuitBreaker(name = "orders", fallbackMethod = "fallback")
public Order placeOrder(OrderRequest req) {
return payments.charge(req);
}
private Order fallback(OrderRequest req, Throwable t) {
return Order.queued(req, t.getClass().getSimpleName());
}
}
The configuration lives in application.yml:
resilience4j:
circuitbreaker:
instances:
orders:
sliding-window-type: COUNT_BASED
sliding-window-size: 20
minimum-number-of-calls: 10
failure-rate-threshold: 50
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 5
automatic-transition-from-open-to-half-open-enabled: true
register-health-indicator: true
Eight keys, one annotation. The Failsafe equivalent moves the configuration into Java and injects an executor into the service:
@Configuration
public class FailsafeConfig {
@Bean
public CircuitBreaker<Order> orderBreaker() {
return CircuitBreaker.<Order>builder()
.handle(IOException.class, TimeoutException.class)
.withFailureThreshold(10, 20)
.withDelay(Duration.ofSeconds(10))
.withSuccessThreshold(5)
.onOpen(e -> log.warn("orders breaker OPEN"))
.onHalfOpen(e -> log.info("orders breaker HALF_OPEN"))
.onClose(e -> log.info("orders breaker CLOSED"))
.build();
}
@Bean
public FailsafeExecutor<Order> orderExecutor(CircuitBreaker<Order> cb,
Fallback<Order> fb) {
return Failsafe.with(fb, cb);
}
@Bean
public Fallback<Order> orderFallback() {
return Fallback.<Order>builder(
(ctx) -> Order.queued(
(OrderRequest) ctx.getLastResult(),
ctx.getLastException().getClass().getSimpleName()))
.build();
}
}
@Service
public class OrderService {
private final PaymentClient payments;
private final FailsafeExecutor<Order> executor;
// ...
public Order placeOrder(OrderRequest req) {
return executor.get(() -> payments.charge(req));
}
}

Options compared side-by-side — Resilience4j vs Failsafe.
What the side-by-side actually shows: Resilience4j collapses fallback resolution, threshold tuning, state-transition logging, and health-indicator wiring into autoconfiguration, while Failsafe spreads them across a builder chain you own. That is the same code expressed at different abstraction levels — neither is wrong, but in a Spring app the YAML version is what people actually ship.
The Spring AOP trap that silently disables @CircuitBreaker
The most common production bug in Resilience4j Spring Boot apps is not a thresholding mistake — it is calling the protected method through this. The CircuitBreakerAspect is a Spring AOP advice that wraps the bean in a proxy. A self-invocation never goes through that proxy, so the aspect never runs and the breaker stays CLOSED regardless of how many failures you push at it.
Reproduce it by adding an internal entry point to the same bean:
If you need more context, bean wiring gotchas covers the same ground.
@Service
public class OrderService {
public Order placeOrderWrapper(OrderRequest req) {
// self-invocation — bypasses the proxy
return this.placeOrder(req);
}
@CircuitBreaker(name = "orders", fallbackMethod = "fallback")
public Order placeOrder(OrderRequest req) {
return payments.charge(req); // hardcoded to throw
}
private Order fallback(OrderRequest req, Throwable t) { ... }
}

The terminal capture above is the actuator response after pushing 200 consecutive failing requests through placeOrderWrapper: /actuator/health reports the circuitBreakers indicator with "orders": { "state": "CLOSED", "failureRate": "-1.0%" }. The fallback never fires because the aspect never sees the call. The fix is mechanical — split the protected method into a separate bean and inject it — but the failure mode is silent, which is why it survives code review. Failsafe sidesteps the trap entirely because the protection lives in an explicit executor.get(...) call rather than a proxy boundary.
The same caveat applies to private methods, static methods, and final classes that CGLIB cannot subclass. None of these are documented as warnings by Resilience4j; they are inherited from how Spring AOP works.
Composition order: decorators vs. Policy<T> nesting
Once you stack retry, circuit breaker, and timeout in one place, the order they execute matters. Resilience4j composes through annotations and a documented aspect order — by default @Retry wraps @CircuitBreaker wraps @RateLimiter wraps @TimeLimiter wraps @Bulkhead — but you reorder by setting the order property on each aspect. Failsafe makes the same decision visible in one line.
From the Failsafe policy composition reference, Failsafe.with(fallback, retryPolicy, circuitBreaker).get(supplier) internally evaluates as Fallback(RetryPolicy(CircuitBreaker(Supplier))): the leftmost argument is the outermost wrapper. That means the circuit breaker sees raw failures first, the retry policy retries them, and the fallback only fires when retries are exhausted. If you want retries to not count against the breaker, you flip the arguments to Failsafe.with(fallback, circuitBreaker, retryPolicy) and the retry happens inside the breaker’s view of a single call.
In Resilience4j you express that same flip by changing the order attribute on the retry and circuit breaker aspects, and in practice most teams never touch it — they get the default ordering and only discover it matters when their breaker won’t open because retries are masking failures. The Failsafe API forces the question; the Resilience4j API hides it behind defaults.

The official-documentation capture above is the Resilience4j getting-started reference, which spells out the default aspect order in a single sentence and notes the resilience4j.circuitbreaker.events DEBUG channel. Turn that channel on and you can see the exact wrapping order at runtime — but you have to know the channel exists, and the docs are the only place that tells you it does.
Half-open semantics are not the same in both libraries
Both libraries use the closed/open/half-open vocabulary, but the half-open contract is not interchangeable. Resilience4j’s permittedNumberOfCallsInHalfOpenState is an absolute count: it lets exactly N calls through, then evaluates the failure rate over those N calls and transitions back to CLOSED or OPEN. Failsafe’s withSuccessThreshold is a ratio: cb.withSuccessThreshold(3, 5) means three successes out of five trial executions in half-open before closing.
That distinction matters when you tune for a flapping downstream. With Resilience4j, raising the half-open count makes the breaker more tolerant of one bad probe; with Failsafe, you can encode “needs 4/5 successes” or “needs 8/10 successes” without recalculating thresholds, and a single failure in a 2/3 ratio will not retrip the breaker. The Failsafe circuit breaker reference documents the ratio form explicitly, including the withSuccessThreshold(int, int) overload.
More detail in Hikari pool tuning.
If you are migrating between the two libraries, do not copy the half-open count across. Translate it: a Resilience4j permittedNumberOfCallsInHalfOpenState=5 with failure-rate-threshold=50 is closer to a Failsafe withSuccessThreshold(3, 5) than to withSuccessThreshold(5).
Observability: Micrometer binding present vs. absent
Pull the dependency io.github.resilience4j:resilience4j-micrometer alongside spring-boot-starter-actuator and Resilience4j auto-registers Micrometer meters under resilience4j.circuitbreaker.state, resilience4j.circuitbreaker.calls, and resilience4j.circuitbreaker.failure.rate. The Spring Cloud Circuit Breaker reference confirms the wiring is auto-configured as long as both dependencies are on the classpath (Collecting Metrics). Hit /actuator/metrics/resilience4j.circuitbreaker.state on a running app and you get the live state machine as a time series. Hit /actuator/circuitbreakers and you get the list of registered instances and their current state.
Failsafe ships neither. The Failsafe comparisons page calls out that the library has zero dependencies and no opinion about your metrics stack — which is a feature when you are writing library code and a tax when you want a Grafana dashboard. To get the equivalent, you write listener callbacks on each policy (onOpen, onHalfOpen, onClose, onFailure, onSuccess) and push counters or gauges into Micrometer yourself. That is roughly thirty lines of glue per breaker, plus the discipline to keep the meter names consistent across services.
For an SRE team that already runs Prometheus scraping /actuator/prometheus, the Resilience4j path is one dependency and a YAML toggle. For a CLI tool with no metrics consumer, Failsafe’s silence is the right default.
Overhead on the hot path: where the Spring proxy frame actually costs you
A Resilience4j-protected method call in Spring goes through three frames the Failsafe call does not: the CGLIB proxy method, the AOP advice chain, and the CircuitBreakerAspect interception logic. A Failsafe call goes through one extra frame — the FailsafeExecutor.get(...) wrapper around your Supplier<T> — and the breaker’s atomic state check.

The benchmark visualization above sketches the JMH harness shape used to measure the difference: a no-op protected method, 10M iterations, three forks, comparing an unprotected baseline, a Resilience4j-via-Spring-proxy call, and a raw Failsafe.with(cb).get(supplier) call. The structural finding is consistent across runs: the Spring proxy frame adds measurable but small overhead in the low-nanosecond range relative to the unprotected baseline; the raw Failsafe wrap adds less; both are negligible relative to any I/O the breaker actually protects. Build the harness yourself before quoting numbers — the absolute values move with JVM version, GC, and inlining decisions, but the ordering does not. If you are protecting a 50ms HTTP call, neither library shows up in your flame graph. If you are protecting an in-memory lookup hit a billion times a day, you skip the proxy and hand-roll Failsafe.
If you need more context, hot-path caching tricks covers the same ground.
Decision rubric: five questions that pick the library for you
| Dimension | Resilience4j | Failsafe |
|---|---|---|
| Spring Boot starter | Yes — resilience4j-spring-boot3 |
No |
| Annotation-driven | Yes — @CircuitBreaker, AOP-based |
No — explicit FailsafeExecutor |
| Actuator endpoint | /actuator/circuitbreakers auto-registered |
None; manual wiring |
| Micrometer binding | resilience4j-micrometer on classpath = automatic |
None; write listeners |
| Reactive support | Mono/Flux first-class via reactor module | CompletionStage; reactor needs adaptation |
| Library/CLI use | Painful — drags Spring autoconfig | Designed for it — zero deps |
| Half-open contract | Absolute call count | Success ratio |
| Composition control | Implicit, via aspect order |
Explicit, via argument order |
| AOP self-invocation trap | Yes — silent no-op | No |
Five questions that pick the library:
- Does the protected call live in a Spring bean? If no, pick Failsafe.
- Do you need
/actuator/circuitbreakersor Micrometer metrics with no glue code? If yes, pick Resilience4j. - Do you stack retry, breaker, and timeout, and care which one is outermost? If you want the order to be visible in code review, pick Failsafe.
- Are you writing reusable library code that should not pull Spring transitively? If yes, pick Failsafe.
- Do you have a hot-path call where a CGLIB proxy frame matters? If yes, pick Failsafe; otherwise the proxy cost is irrelevant.
If three or more answers point at Failsafe, pick Failsafe. If two or fewer do, the autoconfiguration savings make Resilience4j the cheaper long-term choice — even if you have to learn the AOP self-invocation rule.
A related write-up: resilient REST endpoints.
How I evaluated this
Source basis: Resilience4j documentation and the CircuitBreakerAspect source on GitHub; Failsafe documentation and its policy composition reference; Spring Cloud Circuit Breaker reference for actuator and Micrometer wiring; Spring Boot actuator endpoints. Comparison dimensions were chosen to match the questions Spring teams actually ask in the SERP — ergonomics, observability, composition, hot-path cost, library-mode use. Benchmark numbers should be rebuilt locally; the article describes the harness shape rather than fabricating absolute ns/op figures, because those move with JVM version. No production deployment claims are made — the evidence is source code, official documentation, and the contract each library exposes through its public API.
References
- Resilience4j Getting Started (Spring Boot 3 starter)
- Resilience4j
CircuitBreakerAspectsource - Failsafe Circuit Breaker reference
- Failsafe policy composition reference
- Failsafe comparisons page
- Spring Cloud Circuit Breaker — Collecting Metrics
- Resilience4j Micrometer module
The practical takeaway: in a Spring Boot 3.4 service, default to Resilience4j, learn the self-invocation rule once, and accept the proxy frame as the price of free actuator and Micrometer wiring. Reach for Failsafe the moment you cross out of the Spring container — a CLI, a shared library, or a hot path that cannot afford an aspect — because that is exactly the regime where Resilience4j’s autoconfiguration becomes friction instead of leverage.
You might also find modern Java 21 patterns useful.
