Resilience4j Circuit Breaker in Spring Boot: The Modern Replacement for Hystrix

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?

FeatureHystrixResilience4j
Maintenance statusEnd-of-life (maintenance mode since 2018)Actively maintained
Spring Boot 3 supportNoneFull (dedicated starter)
Isolation modelThread-pool per command (default)Semaphore-based; threading is explicit and yours
Reactive supportRxJava 1 onlyProject Reactor + RxJava 3
ArchitectureCommand classes + ThreadLocalFunctional decorators + annotations
Timeout handlingBuilt into every commandSeparate, 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 propertyResilience4j equivalent
circuitBreaker.requestVolumeThresholdminimumNumberOfCalls
circuitBreaker.errorThresholdPercentagefailureRateThreshold
circuitBreaker.sleepWindowInMillisecondswaitDurationInOpenState
metrics.rollingStats.timeInMillisecondsslidingWindowType: TIME_BASED + slidingWindowSize
execution.isolation.thread.timeoutInMillisecondsTimeLimiter timeoutDuration (async only!)
threadpool.coreSizeBulkhead 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

Further Reading

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.