Netflix Hystrix is in maintenance mode and has been since 2018. The modern replacement is Resilience4j — a lightweight, modular fault-tolerance library with first-class Spring Boot integration. In this guide you will implement all five core patterns — Circuit Breaker, Retry, Rate Limiter, Bulkhead, and TimeLimiter — in Spring Boot 3.x, with working configuration, the Hystrix-to-Resilience4j property mapping I wish I’d had during my own migration, and the three bugs that bite almost every team on day one.
Tested with: Spring Boot 3.4, resilience4j-spring-boot3 2.2.0, Java 21. If you are coming from the Netflix stack, the Spring Cloud Netflix migration guide covers the bigger picture; this post goes deep on the resilience layer.
Why Resilience4j Over Hystrix?
| Feature | Hystrix | Resilience4j |
|---|---|---|
| Maintenance status | End-of-life (maintenance mode since 2018) | Actively maintained |
| Spring Boot 3 support | None | Full (dedicated starter) |
| Isolation model | Thread-pool per command (default) | Semaphore-based; threading is explicit and yours |
| Reactive support | RxJava 1 only | Project Reactor + RxJava 3 |
| Architecture | Command classes + ThreadLocal | Functional decorators + annotations |
| Timeout handling | Built into every command | Separate, opt-in TimeLimiter module |
That last two rows matter most for migrations: Hystrix wrapped every call in its own thread pool and killed slow calls automatically. Resilience4j does neither unless you configure it. Teams that miss this ship a “migrated” service whose calls can hang forever.
Maven Dependencies
<!-- Spring Boot 3 starter: Circuit Breaker, Retry, Rate Limiter, Bulkhead, TimeLimiter -->
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>2.2.0</version>
</dependency>
<!-- Spring AOP is REQUIRED for the annotations to work -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- Actuator for metrics and health-indicator integration -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
Forgetting spring-boot-starter-aop is the most common setup failure: the application starts cleanly, the annotations are silently ignored, and you only find out when the downstream service dies and nothing trips.
Circuit Breaker
The circuit breaker tracks failures over a sliding window and trips — returning immediately via fallback — when the failure rate crosses a threshold, then half-opens to probe recovery.
resilience4j:
circuitbreaker:
instances:
paymentService:
slidingWindowType: COUNT_BASED
slidingWindowSize: 10 # evaluate last 10 calls
minimumNumberOfCalls: 5 # don't judge before 5 calls recorded
failureRateThreshold: 50 # trip at 50% failures
slowCallDurationThreshold: 2s # calls slower than 2s count as slow
slowCallRateThreshold: 80 # trip if 80% of calls are slow
waitDurationInOpenState: 10s
permittedNumberOfCallsInHalfOpenState: 3
automaticTransitionFromOpenToHalfOpenEnabled: true
recordExceptions:
- java.io.IOException
- java.util.concurrent.TimeoutException
ignoreExceptions:
- com.example.BusinessValidationException # 4xx-style errors must NOT trip the breaker
Two settings Hystrix users overlook: minimumNumberOfCalls (prevents one failed call at startup from tripping a fresh breaker) and ignoreExceptions (a client-side validation error is not a sign the downstream is unhealthy — counting it poisons your failure rate).
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
@Service
public class PaymentService {
private final RestClient restClient;
public PaymentService(RestClient.Builder builder) {
this.restClient = builder.baseUrl("http://payment-gateway").build();
}
// name must match the instance key in application.yml
@CircuitBreaker(name = "paymentService", fallbackMethod = "paymentFallback")
public String processPayment(String orderId) {
return restClient.get().uri("/pay/{id}", orderId)
.retrieve()
.body(String.class);
}
// Same parameters as the protected method PLUS a Throwable — mandatory
private String paymentFallback(String orderId, Throwable ex) {
return "Payment unavailable for order " + orderId +
" (" + ex.getClass().getSimpleName() + "). Queued for retry.";
}
}
TimeLimiter: The Hystrix Timeout You Lost
Hystrix timed out every command at 1 second by default. Resilience4j’s TimeLimiter only works on async return types (CompletableFuture, Mono, Flux) — it cannot interrupt a plain blocking call:
resilience4j:
timelimiter:
instances:
paymentService:
timeoutDuration: 2s
cancelRunningFuture: true
@TimeLimiter(name = "paymentService", fallbackMethod = "timeoutFallback")
@CircuitBreaker(name = "paymentService", fallbackMethod = "timeoutFallback")
public CompletableFuture<String> processPaymentAsync(String orderId) {
return CompletableFuture.supplyAsync(() ->
restClient.get().uri("/pay/{id}", orderId).retrieve().body(String.class));
}
private CompletableFuture<String> timeoutFallback(String orderId, Throwable ex) {
return CompletableFuture.completedFuture("Payment timed out for " + orderId);
}
For synchronous code, set timeouts on the HTTP client itself (connect/read timeouts on RestClient’s request factory) and let the circuit breaker’s slowCallRateThreshold handle chronic slowness. Do not pretend TimeLimiter protects blocking calls — it doesn’t.
Retry
resilience4j:
retry:
instances:
inventoryService:
maxAttempts: 3
waitDuration: 500ms
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2 # 500ms, 1s, 2s
retryExceptions:
- java.io.IOException
ignoreExceptions:
- com.example.BusinessValidationException
import io.github.resilience4j.retry.annotation.Retry;
@Service
public class InventoryService {
@Retry(name = "inventoryService", fallbackMethod = "inventoryFallback")
public String checkStock(String productId) {
return restClient.get().uri("/stock/{id}", productId)
.retrieve()
.body(String.class);
}
private String inventoryFallback(String productId, Throwable ex) {
return "Stock check failed after retries for " + productId;
}
}
Only retry idempotent operations. Retrying a non-idempotent POST that timed out after the downstream processed it is how duplicate payments happen.
Rate Limiter
resilience4j:
ratelimiter:
instances:
publicApi:
limitForPeriod: 100 # 100 calls
limitRefreshPeriod: 1s # per second
timeoutDuration: 0ms # fail immediately when exhausted
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
@RestController
@RequestMapping("/api")
public class PublicApiController {
@GetMapping("/search")
@RateLimiter(name = "publicApi", fallbackMethod = "searchFallback")
public ResponseEntity<String> search(@RequestParam String q) {
return ResponseEntity.ok("Results for: " + q);
}
private ResponseEntity<String> searchFallback(String q, Throwable ex) {
return ResponseEntity.status(429).body("Rate limit exceeded. Try again later.");
}
}
Note this is an in-process limiter — with three replicas behind a load balancer your effective limit is 300/s, not 100/s. Cluster-wide limiting needs a gateway-level limiter (Spring Cloud Gateway’s Redis RateLimiter) instead.
Bulkhead
resilience4j:
bulkhead:
instances:
paymentService:
maxConcurrentCalls: 10
maxWaitDuration: 100ms
import io.github.resilience4j.bulkhead.annotation.Bulkhead;
import io.github.resilience4j.bulkhead.BulkheadFullException;
@Bulkhead(name = "paymentService", fallbackMethod = "bulkheadFallback")
public String processPayment(String orderId) { /* ... */ }
private String bulkheadFallback(String orderId, BulkheadFullException ex) {
return "Too many concurrent payment requests. Retry shortly.";
}
The default semaphore bulkhead limits concurrency without extra threads. If you genuinely need Hystrix-style thread isolation, the separate THREADPOOL bulkhead exists — but on Java 21 with virtual threads, semaphore isolation plus client timeouts is usually the simpler and better answer.
Combining Annotations and Aspect Order
When stacked, the default evaluation order (outermost first) is: Bulkhead → TimeLimiter → RateLimiter → CircuitBreaker → Retry. The practical consequence: Retry sits inside the circuit breaker, so every failed retry attempt is recorded by the breaker. If you want three retries to count as one failure instead, invert them:
resilience4j:
# Higher order = evaluated closer to the method (inner)
retry.retryAspectOrder: 1
circuitbreaker.circuitBreakerAspectOrder: 2 # breaker now wraps INSIDE retry
Hystrix → Resilience4j Property Mapping
| Hystrix property | Resilience4j equivalent |
|---|---|
| circuitBreaker.requestVolumeThreshold | minimumNumberOfCalls |
| circuitBreaker.errorThresholdPercentage | failureRateThreshold |
| circuitBreaker.sleepWindowInMilliseconds | waitDurationInOpenState |
| metrics.rollingStats.timeInMilliseconds | slidingWindowType: TIME_BASED + slidingWindowSize |
| execution.isolation.thread.timeoutInMilliseconds | TimeLimiter timeoutDuration (async only!) |
| threadpool.coreSize | Bulkhead maxConcurrentCalls (semaphore) or THREADPOOL bulkhead |
| fallbackMethod (Hystrix) | fallbackMethod — but signature must now include Throwable |
Monitoring with Actuator
management:
endpoints:
web:
exposure:
include: health,metrics,circuitbreakers,circuitbreakerevents
health:
circuitbreakers:
enabled: true
Sample output from GET /actuator/health with one breaker tripped:
{
"status": "UP",
"components": {
"circuitBreakers": {
"status": "UNKNOWN",
"details": {
"paymentService": {
"status": "CIRCUIT_OPEN",
"details": {
"failureRate": "60.0%",
"state": "OPEN",
"bufferedCalls": 10,
"failedCalls": 6
}
}
}
}
}
}
For dashboards, the resilience4j_circuitbreaker_state and resilience4j_circuitbreaker_calls_seconds Micrometer metrics feed straight into the Prometheus & Grafana stack — the modern replacement for the Hystrix Dashboard.
Pitfalls That Bite Every Migration
1. Wrong fallback signature. The fallback must mirror the protected method’s parameters and add a Throwable (or a more specific exception) as the last parameter. Get it wrong and Resilience4j throws NoSuchMethodException at call time — not at startup.
2. Self-invocation. The annotations work via Spring AOP proxies. Calling an annotated method from within the same class bypasses the proxy entirely — no breaker, no retry, no error. Move the protected method to a separate bean.
3. Assuming timeouts exist. Covered above: no TimeLimiter or HTTP-client timeout means a hung downstream hangs you too. Hystrix protected you by default; Resilience4j makes you ask.
4. Counting business exceptions as failures. Without ignoreExceptions, a burst of validation errors (effectively 4xx) trips the breaker and takes down a perfectly healthy integration.
Frequently Asked Questions
Can I run Hystrix and Resilience4j side by side during migration? Yes — no dependency conflicts. Migrate one service or endpoint at a time.
Do I need Spring Cloud CircuitBreaker? Only if you want a vendor-neutral abstraction. Using resilience4j-spring-boot3 directly gives you full access to every configuration knob and is what I recommend.
Does Resilience4j work with virtual threads? Yes. Semaphore-based bulkheads and circuit breakers are agnostic to the carrier thread; just avoid the THREADPOOL bulkhead with virtual threads — it defeats their purpose. See virtual threads in Spring Boot.
AI Prompts for Resilience4j
Convert Hystrix Class
Convert this Hystrix-annotated class to Resilience4j on Spring Boot 3: [paste class here]. Map every hystrix.command property to the resilience4j YAML equivalent, fix the fallback signatures to include Throwable, and flag any timeout behaviour that changes.
What it does: Performs the mechanical conversion and surfaces the semantic timeout/isolation differences explicitly.
When to use it: Per-class during a Hystrix migration.
Tune Breaker Thresholds
Given this traffic profile — [paste p50/p99 latency, request rate, downstream SLA here] — recommend Resilience4j circuit breaker settings (window type/size, failure and slow-call thresholds, wait duration) and justify each number.
What it does: Turns real latency data into defensible threshold values instead of copy-pasted defaults.
When to use it: Before production rollout, once you have metrics from staging.
Audit Resilience Gaps
Review these service classes: [paste here]. Identify every external call lacking a circuit breaker, timeout, or retry; flag non-idempotent operations that must NOT be retried; and list self-invocation cases where annotations will silently not fire.
What it does: Finds unprotected calls and the AOP self-invocation trap in one pass.
When to use it: During code review of any service touching the network.
Design Fallback Strategy
For this endpoint and its business context: [paste method and description here], propose three fallback strategies (cached value, degraded response, queued retry), with the trade-offs of each for data freshness and user experience.
What it does: Moves fallback design beyond “return an error string” to real degradation strategies.
When to use it: When defining behaviour for user-facing endpoints under failure.
Write Breaker Tests
Write JUnit tests for this Resilience4j-protected service: [paste here]. Cover breaker tripping after the configured failures, fallback invocation, half-open recovery, and ignored exceptions not affecting failure rate. Use CircuitBreakerRegistry directly to assert state transitions.
What it does: Generates state-transition tests most teams skip until the breaker misbehaves in production.
When to use it: Right after wiring up each breaker instance.
Conclusion
Resilience4j is the definitive replacement for Hystrix, but it is not a drop-in one: timeouts, thread isolation, and fallback signatures all changed semantics. Configure minimumNumberOfCalls and ignoreExceptions on every breaker, give every blocking call an HTTP-client timeout, and keep retries strictly idempotent. Start with one breaker on your flakiest external dependency, watch the Actuator metrics for a week, then roll the pattern outward.
See Also
- Spring Cloud Netflix to Modern Alternatives: Complete Migration Guide
- Zuul to Spring Cloud Gateway Migration Guide
- RestTemplate to RestClient Migration Guide
- Monitoring Spring Boot Microservices with Prometheus & Grafana
- Spring Cloud: Adding Hystrix Circuit Breaker (Legacy)