Circuit Breaker vs Rate Limiter vs Bulkhead: When to Use Which Pattern
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
| Scenario | Circuit Breaker | Rate Limiter | Bulkhead |
|---|---|---|---|
| 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:
- Circuit Breaker: Downstream service failing → fail fast
- Rate Limiter: Too much incoming traffic → reject excess
- Bulkhead: Isolate failures → one dependency can’t consume all resources
Using the wrong pattern is worse than no pattern at all.
Related Articles
- Connection Pool Sizing with Little’s Law - Pool sizing
- K8s CPU Throttling Autopsy - Resource limits
Related posts
Java Virtual Threads vs Reactive: When to Drop WebFlux for Project Loom
Virtual Threads in Java 21 promise simpler code than Reactive. I benchmark both under 10k concurrent connections and show where each wins.
Circuit Breaker Anti-Patterns: When Protection Causes Outages
Circuit breakers prevent cascading failures but wrong config makes them worse. I show 5 anti-patterns: shared breakers, wrong thresholds, instant open, no fallback, and testing gaps.
When Prepared Statements Make PostgreSQL 10× Slower: Generic Plan Trap
Same query, same params, but prod is slow and staging works fine. I'll show how to reproduce the generic plan problem with pgBouncer, Java/Go and how to fix it.
gRPC Deadline Propagation: Preventing Cascading Failures
Frontend gives up after 5s but backend keeps working for 30s. Without deadline propagation, you waste resources on doomed requests. I show how to implement it in Go.
Cite this article
If you reference this post, please link to the original URL and credit the author.