Back to blog

Circuit Breaker vs Rate Limiter vs Bulkhead: When to Use Which Pattern

|
| resilience, circuit-breaker, rate-limiter, bulkhead, java, spring-boot, resilience4j

We added all three patterns and still got paged; the problem was how they interacted. “Let’s add a circuit breaker everywhere.” This is how you create a system that fails in creative ways nobody anticipated.

Circuit breaker, rate limiter, and bulkhead solve different problems. Using the wrong one makes your system less resilient, not more.

Tested on: Resilience4j 2.2, Spring Boot 3.2, Java 21, Prometheus + Grafana

Problem Definition

Circuit Breaker: Fail Fast for Downstream Failures

Purpose: Stop calling a failing service
Protects: YOUR system from wasting resources on hopeless calls
Trigger: Downstream failures (5xx, timeouts)

Without circuit breaker:
Service A → Service B (down)
         → Threads blocked waiting for timeout
         → Thread pool exhausted
         → Service A also fails (cascade)

With circuit breaker:
Service A → Circuit OPEN → Immediate failure (no waiting)
         → Resources preserved
         → Service A continues working (degraded)

Rate Limiter: Protect from Overload

Purpose: Limit incoming request rate
Protects: DOWNSTREAM system from overload
Trigger: Too many requests (regardless of success/failure)

Without rate limiter:
100,000 RPS → Service → Database
                      → Database overloaded
                      → Everything fails

With rate limiter:
100,000 RPS → Rate Limiter (1000 RPS) → Service → Database
           → 99,000 rejected immediately
           → Database happy, 1000 requests succeed

Bulkhead: Isolation of Failure Domains

Purpose: Isolate resources per dependency
Protects: System from single dependency consuming all resources
Trigger: Resource exhaustion (threads, connections)

Without bulkhead:
Service A → Service B (slow) → Uses all 200 threads
         → Service C (fast) → No threads available!
         → Both B and C calls fail

With bulkhead:
Service A → Service B → Bulkhead (50 threads max)
         → Service C → Bulkhead (50 threads max)
         → Service B slow? Only 50 threads blocked
         → Service C still works!

Decision Matrix

ScenarioCircuit BreakerRate LimiterBulkhead
Downstream service failing
Too many incoming requests
One slow dependency
Prevent cascade failure
Protect shared resource
Client-side protection
Server-side protection

Implementation with Resilience4j

Circuit Breaker

// CircuitBreakerConfig.java
@Configuration
public class CircuitBreakerConfig {

    @Bean
    public CircuitBreakerRegistry circuitBreakerRegistry() {
        CircuitBreakerConfig config = CircuitBreakerConfig.custom()
            // Open circuit after 50% failures
            .failureRateThreshold(50)
            // ... in last 10 calls
            .slidingWindowType(SlidingWindowType.COUNT_BASED)
            .slidingWindowSize(10)
            // Stay open for 30 seconds
            .waitDurationInOpenState(Duration.ofSeconds(30))
            // Allow 5 test calls in half-open state
            .permittedNumberOfCallsInHalfOpenState(5)
            // Record these as failures
            .recordExceptions(IOException.class, TimeoutException.class)
            // Don't record these
            .ignoreExceptions(BusinessException.class)
            .build();

        return CircuitBreakerRegistry.of(config);
    }
}

// PaymentService.java
@Service
public class PaymentService {

    private final CircuitBreaker circuitBreaker;
    private final PaymentClient paymentClient;

    public PaymentService(CircuitBreakerRegistry registry, PaymentClient client) {
        this.circuitBreaker = registry.circuitBreaker("payment-service");
        this.paymentClient = client;
    }

    public PaymentResult processPayment(PaymentRequest request) {
        return circuitBreaker.executeSupplier(() ->
            paymentClient.charge(request)
        );
    }

    // With fallback
    public PaymentResult processPaymentWithFallback(PaymentRequest request) {
        return Try.ofSupplier(
            CircuitBreaker.decorateSupplier(circuitBreaker,
                () -> paymentClient.charge(request))
        ).recover(CallNotPermittedException.class,
            ex -> PaymentResult.queued("Circuit open, queued for retry")
        ).recover(Exception.class,
            ex -> PaymentResult.failed(ex.getMessage())
        ).get();
    }
}

Rate Limiter

// RateLimiterConfig.java
@Configuration
public class RateLimiterConfig {

    @Bean
    public RateLimiterRegistry rateLimiterRegistry() {
        RateLimiterConfig config = RateLimiterConfig.custom()
            // 100 requests per second
            .limitForPeriod(100)
            .limitRefreshPeriod(Duration.ofSeconds(1))
            // Wait max 500ms for permit
            .timeoutDuration(Duration.ofMillis(500))
            .build();

        return RateLimiterRegistry.of(config);
    }
}

// ApiController.java
@RestController
public class ApiController {

    private final RateLimiter rateLimiter;

    public ApiController(RateLimiterRegistry registry) {
        this.rateLimiter = registry.rateLimiter("api");
    }

    @GetMapping("/data")
    public ResponseEntity<Data> getData() {
        return RateLimiter.decorateSupplier(rateLimiter, () ->
            ResponseEntity.ok(dataService.fetch())
        ).get();
    }
}

// Or with annotations (Spring Boot)
@Service
public class DataService {

    @RateLimiter(name = "api", fallbackMethod = "fallback")
    public Data fetch() {
        return repository.findAll();
    }

    public Data fallback(RequestNotPermitted ex) {
        throw new TooManyRequestsException("Rate limit exceeded");
    }
}

Bulkhead

// BulkheadConfig.java
@Configuration
public class BulkheadConfig {

    @Bean
    public ThreadPoolBulkheadRegistry bulkheadRegistry() {
        ThreadPoolBulkheadConfig config = ThreadPoolBulkheadConfig.custom()
            // Max 10 concurrent calls
            .maxThreadPoolSize(10)
            .coreThreadPoolSize(5)
            // Queue 20 waiting calls
            .queueCapacity(20)
            // Keep idle threads for 60s
            .keepAliveDuration(Duration.ofSeconds(60))
            .build();

        return ThreadPoolBulkheadRegistry.of(config);
    }
}

// ExternalServices.java
@Service
public class ExternalServices {

    private final ThreadPoolBulkhead paymentBulkhead;
    private final ThreadPoolBulkhead inventoryBulkhead;
    private final ThreadPoolBulkhead notificationBulkhead;

    public ExternalServices(ThreadPoolBulkheadRegistry registry) {
        // Each service gets isolated thread pool
        this.paymentBulkhead = registry.bulkhead("payment");
        this.inventoryBulkhead = registry.bulkhead("inventory");
        this.notificationBulkhead = registry.bulkhead("notification");
    }

    public CompletableFuture<PaymentResult> processPayment(Order order) {
        return paymentBulkhead.executeSupplier(() ->
            paymentClient.charge(order)
        );
    }

    public CompletableFuture<InventoryResult> reserveInventory(Order order) {
        return inventoryBulkhead.executeSupplier(() ->
            inventoryClient.reserve(order)
        );
    }
}

Combining Patterns

Correct: Circuit Breaker + Bulkhead

// Correct combination: CB inside Bulkhead
@Service
public class OrderService {

    private final CircuitBreaker circuitBreaker;
    private final ThreadPoolBulkhead bulkhead;

    public CompletableFuture<OrderResult> createOrder(Order order) {
        // 1. Bulkhead limits concurrent calls (resource isolation)
        // 2. Circuit breaker fails fast if service is down
        Supplier<OrderResult> decoratedSupplier =
            CircuitBreaker.decorateSupplier(circuitBreaker, () ->
                externalService.process(order)
            );

        return bulkhead.executeSupplier(decoratedSupplier);
    }
}

Correct: Rate Limiter on Entry Point

// Rate limiter at API gateway / controller level
@RestController
@RateLimiter(name = "api")  // First line of defense
public class OrderController {

    @PostMapping("/orders")
    public Order createOrder(@RequestBody OrderRequest request) {
        // CB + Bulkhead inside for downstream calls
        return orderService.create(request);
    }
}

Wrong: Rate Limiter for Downstream Failures

// WRONG: Rate limiter won't help here!
@Service
public class PaymentService {

    @RateLimiter(name = "payment")  // Won't help if payment service is DOWN
    public PaymentResult charge(Payment payment) {
        return paymentClient.charge(payment);  // Fails regardless of rate
    }

    // CORRECT: Use circuit breaker
    @CircuitBreaker(name = "payment")
    public PaymentResult chargeCorrect(Payment payment) {
        return paymentClient.charge(payment);
    }
}

Benchmarks

Scenario: Payment Service Goes Down

Setup:
- 100 RPS incoming
- Payment service: 30s timeout → then fails
- Thread pool: 200 threads

WITHOUT any pattern:
- All 200 threads blocked waiting for timeout
- After 2 seconds: thread pool exhausted
- All requests fail (including non-payment ones)

WITH Circuit Breaker only:
- First 10 calls: timeout (30s each)
- Circuit opens after 50% failure
- Subsequent calls: immediate failure (< 1ms)
- Thread pool usage: max 10 threads
- Non-payment endpoints: unaffected

WITH Bulkhead only:
- Payment bulkhead: 20 threads max
- All 20 threads blocked (30s timeout)
- Non-payment endpoints: work fine (180 threads available)
- BUT: payment requests still wait 30s before failing

WITH Circuit Breaker + Bulkhead:
- First 10 calls: timeout in bulkhead (max 20 threads)
- Circuit opens
- Subsequent calls: immediate failure
- Non-payment endpoints: unaffected
- Thread usage: minimal

Load Test Results

Target: 1000 RPS, Payment service down

| Configuration | p99 Latency | Error Rate | Thread Usage |
|---------------|-------------|------------|--------------|
| No protection | 30,000ms | 100% | 200/200 |
| CB only | 50ms | 100%* | 15/200 |
| Bulkhead only | 30,000ms | 100% | 20/200 |
| CB + Bulkhead | 50ms | 100%* | 10/200 |
| Rate Limiter | 30,000ms | 100% | 200/200 |

* Errors are fast failures, not timeouts

Monitoring

Prometheus Metrics

// Resilience4j auto-exposes metrics
// application.yml
resilience4j:
  circuitbreaker:
    configs:
      default:
        registerHealthIndicator: true
    instances:
      payment:
        baseConfig: default

management:
  metrics:
    tags:
      application: order-service

Key Metrics

# Circuit Breaker state
resilience4j_circuitbreaker_state{name="payment"}
# 0=CLOSED, 1=OPEN, 2=HALF_OPEN

# Failure rate
resilience4j_circuitbreaker_failure_rate{name="payment"}

# Bulkhead available threads
resilience4j_bulkhead_available_concurrent_calls{name="payment"}

# Rate limiter available permits
resilience4j_ratelimiter_available_permissions{name="api"}

Alerts

groups:
- name: resilience
  rules:
  - alert: CircuitBreakerOpen
    expr: resilience4j_circuitbreaker_state == 1
    for: 1m
    labels:
      severity: warning
    annotations:
      summary: "Circuit breaker {{ $labels.name }} is OPEN"

  - alert: BulkheadSaturated
    expr: resilience4j_bulkhead_available_concurrent_calls < 2
    for: 5m
    labels:
      severity: warning
    annotations:
      summary: "Bulkhead {{ $labels.name }} near capacity"

Checklist

## Resilience Pattern Selection

### Circuit Breaker - Use when:
- [ ] Downstream service can fail or be slow
- [ ] You want fast failure instead of timeout
- [ ] Cascading failures are a risk

### Rate Limiter - Use when:
- [ ] Protecting from traffic spikes
- [ ] API has usage quotas
- [ ] Database/service has capacity limit

### Bulkhead - Use when:
- [ ] Multiple dependencies share thread pool
- [ ] One slow dependency shouldn't affect others
- [ ] Resource isolation is needed

### Combination Rules:
- [ ] Circuit Breaker: per downstream service
- [ ] Rate Limiter: at API entry point
- [ ] Bulkhead: isolate different dependencies
- [ ] Never: Rate Limiter for downstream failures

Conclusion

Three patterns, three different purposes:

  1. Circuit Breaker: Downstream service failing → fail fast
  2. Rate Limiter: Too much incoming traffic → reject excess
  3. Bulkhead: Isolate failures → one dependency can’t consume all resources

Using the wrong pattern is worse than no pattern at all.


Related posts

Cite this article

If you reference this post, please link to the original URL and credit the author.

Michal Drozd. "Circuit Breaker vs Rate Limiter vs Bulkhead: When to Use Which Pattern". https://www.michal-drozd.com/en/blog/circuit-breaker-rate-limiter-bulkhead/ (Published September 19, 2025).