What Resilience4j does in a Spring Boot service and when you need it
Resilience4j is a lightweight fault-tolerance library for Java. In a Spring Boot service it wraps outbound calls with small, composable policies. Each policy addresses one way a dependency can hurt you:
- Circuit breaker: stops calling a dependency whose recent failure or slow-call rate crosses a threshold, then probes it before resuming.
- Bulkhead: caps concurrent calls to one dependency so it cannot hold every request thread.
- Time limiter: bounds how long the caller waits for a result.
- Retry: re-attempts calls that failed for transient reasons.
These patterns limit blast radius. Without them, one slow downstream service ties up your threads and connection pool. Your own latency then climbs, and callers that have nothing to do with that dependency start failing too.
Who should use it
Use Resilience4j if you own a service that makes synchronous outbound calls, such as HTTP, gRPC or database calls, where a single dependency's bad day can degrade everything else. It fits best when these conditions hold:
- The service calls several downstream dependencies with different reliability, and you want each one isolated from the others.
- Traffic is high enough for sliding windows to fill, so failure and slow-call rates mean something.
- You need fallbacks that depend on business context, such as serving a cached price or an empty recommendation list, which infrastructure-level retries and timeouts cannot provide.
- The team owning the service also owns its on-call. Thresholds and fallbacks need regular tuning, and the people who see the incidents should be the ones adjusting them.
When it should be avoided
Skip it, or use only part of it, in these cases:
- The service has no outbound dependencies.
- Traffic is so low that sliding-window thresholds never fill and the breaker never trips.
- The real problem is that the downstream lacks capacity. A breaker only fails fast. It does not add capacity, and that needs server-side work.
- The outbound work is asynchronous messaging, where the broker already handles redelivery and backpressure. Wrapping a fire-and-forget publish in a breaker adds configuration without protecting any request thread.
- A proxy or service mesh already applies retries and timeouts to the same calls, and you need no application-level fallback. Adding Resilience4j retries on top multiplies attempts against an already struggling dependency.
- The operation is not idempotent and you were planning to add Retry. Use a breaker and bulkhead without retries, or make the operation idempotent first.
The example used throughout
The tutorial follows one call: an order-service requesting prices from pricing-service over HTTP. Assumed budget (illustrative, not measured):
- p99 latency: 150 ms
- Hard caller deadline: 400 ms
- Peak concurrency: about 30 in-flight calls
How Resilience4j works
Resilience4j is built from separate modules, one per pattern, and each works the same way: it decorates a functional interface. You hand it a Supplier, Callable, Runnable, Function or CompletionStage supplier, and it returns a wrapped version of that function. The wrapper runs its own logic before and after delegating to your code. A circuit breaker checks whether it may admit the call and then records the outcome. A bulkhead acquires and releases a permit. A retry loops on matching exceptions. Because each decorator returns the same functional type it received, decorators stack: the output of one becomes the input of the next.
Each pattern has a registry, such as CircuitBreakerRegistry or BulkheadRegistry, that creates and caches named instances. An instance is a stateful object held in memory in your JVM. A circuit breaker instance is a small state machine holding its sliding window of recent outcomes and its current state. A bulkhead instance holds its permit count. Every call that uses the name pricing shares the same instance, so all of them contribute to the same failure rate and compete for the same permits. Because that state is in-process, each replica of your service keeps its own independent copy.
Instances publish events, such as successful calls, recorded errors, state transitions and rejected calls, through an event publisher. The Micrometer integration builds metrics from these events and from instance state, and you can attach your own listeners for logging.
The Spring Boot integration adds three things on top of the core library. It creates the registries as beans. It binds application.yml properties to named instance configurations. It registers AOP aspects so that annotations such as @CircuitBreaker(name = "pricing") look up the named instance and apply the decorator around the annotated method at runtime. The annotations are therefore a declarative route to the same decorators you can build by hand, and they inherit the limits of Spring proxies, covered in the next section.
Wiring Resilience4j into Spring Boot: starter, annotations and programmatic decorators
Add the Resilience4j Spring Boot starter that matches your Boot generation (resilience4j-spring-boot3 for Spring Boot 3) and spring-boot-starter-aop. The annotations are implemented as Spring AOP aspects, so without the AOP starter they compile and do nothing: no error, no protection. The starter also auto-configures one registry per pattern and binds instance settings from resilience4j.circuitbreaker.instances.pricing.* and the equivalent keys for the other patterns.
Annotation-driven
On the PricingClient method, stack @CircuitBreaker(name = "pricing", fallbackMethod = "cachedPrice"), @Bulkhead(name = "pricing"), @TimeLimiter(name = "pricing") and @Retry(name = "pricing"). The name is the key that ties each annotation to its configuration block. @TimeLimiter only works on methods that return CompletableFuture/CompletionStage or a reactive type. A blocking PriceQuote getPrice(...) has nothing for it to cancel.
Programmatic
Inject CircuitBreakerRegistry, BulkheadRegistry and the other registries, fetch instances by name, and compose them with Decorators.ofSupplier(...) or ofCompletionStage(...). This reads the same configuration but makes wrapping explicit and visible in the code.
The proxy trap
Annotations protect only calls that pass through the Spring proxy. A call from inside PricingClient through this.getPrice() bypasses the proxy and runs unprotected without any warning. The same applies to private methods, final methods on class-based proxies, and objects created with new instead of injected.
Switch to programmatic decorators in these cases:
- The guarded call must live inside the same class.
- The client is not a Spring bean.
- You need per-call composition that you can check in a unit test without a Spring context.
// Annotation-driven: works only when callers reach this bean through the Spring proxy.
@Service
public class PricingClient {
private final PricingApi api;
PricingClient(PricingApi api) { this.api = api; }
@CircuitBreaker(name = "pricing", fallbackMethod = "cachedPrice")
@Bulkhead(name = "pricing")
@TimeLimiter(name = "pricing") // needs an async return type to have something to cancel
@Retry(name = "pricing")
public CompletableFuture<PriceQuote> getPrice(String sku) {
return CompletableFuture.supplyAsync(() -> api.fetch(sku));
}
// Fallback: same parameters plus a trailing Throwable, same return type.
private CompletableFuture<PriceQuote> cachedPrice(String sku, Throwable t) {
return CompletableFuture.completedFuture(PriceQuote.cached(sku));
}
public PriceQuote getPriceTwice(String sku) {
return this.getPrice(sku).join(); // TRAP: self-invocation skips the proxy and is unprotected
}
}
// Programmatic: the same named config from application.yml, with explicit wrapping.
// Decorators comes from the resilience4j-all module.
@Service
class GuardedPricing {
private final Supplier<PriceQuote> guarded;
GuardedPricing(PricingApi api, CircuitBreakerRegistry cbs, BulkheadRegistry bhs, RetryRegistry rs) {
this.guarded = Decorators.ofSupplier(() -> api.fetch("SKU-1234"))
.withCircuitBreaker(cbs.circuitBreaker("pricing"))
.withBulkhead(bhs.bulkhead("pricing"))
.withRetry(rs.retry("pricing"))
.withFallback(t -> PriceQuote.cached("SKU-1234"))
.decorate();
}
PriceQuote price() { return guarded.get(); } // protected no matter who calls it
}
The example stacks the four annotations on an async PricingClient method and shows a self-invocation call that bypasses the proxy. It then builds the same protection programmatically by taking named instances from the registries and composing them with Decorators.
How the circuit breaker works and how to configure it per dependency
A breaker starts CLOSED and lets calls through while recording outcomes in a sliding window. When the failure rate or the slow-call rate crosses its threshold, it moves to OPEN and rejects calls immediately with CallNotPermittedException. This protects your threads from a dependency that is already struggling. After waitDurationInOpenState, it moves to HALF_OPEN and admits permittedNumberOfCallsInHalfOpenState trial calls. Their outcome decides whether it closes again or reopens.
| Setting | Count-based window | Time-based window |
|---|---|---|
slidingWindowSize means |
last N calls | last N seconds |
| Behaves well when | traffic is steady | traffic is bursty |
| Risk | stale data at low traffic | uneven sample sizes |
No rate is evaluated until minimumNumberOfCalls is reached. This prevents two failures out of three calls from opening the breaker.
Set slowCallDurationThreshold below your timeout. A dependency that answers at 1.9s against a 2s timeout is already failing your users, even though no call errors.
Use recordExceptions and ignoreExceptions to decide what counts as a failure. A 404 or 422 is the caller's fault, not the dependency's, so ignore client-error exceptions and record only 5xx responses, timeouts and connection errors.
Put shared values under resilience4j.circuitbreaker.configs.default. Then declare one entry per dependency under instances that sets baseConfig: default and overrides only what differs. Each downstream service then trips on its own, and a failing payments API cannot block inventory calls.
At low traffic the window settings conflict. A small window flaps on a single error, while a large minimumNumberOfCalls may never be reached, so the breaker never opens.
Breaker state is also local to each replica and is not shared across the fleet. With 20 pods, each one must observe failures independently before it opens.
resilience4j:
circuitbreaker:
configs:
default:
registerHealthIndicator: true
slidingWindowType: COUNT_BASED
slidingWindowSize: 50 # evaluate the last 50 calls
minimumNumberOfCalls: 20 # no rate is computed before 20 calls
failureRateThreshold: 50 # percent of failed calls that opens the breaker
slowCallRateThreshold: 60 # percent of slow calls that opens the breaker
slowCallDurationThreshold: 1500ms # keep below the client timeout (e.g. 2s)
waitDurationInOpenState: 30s
permittedNumberOfCallsInHalfOpenState: 5
automaticTransitionFromOpenToHalfOpenEnabled: true
recordExceptions: # dependency faults: 5xx, timeouts, I/O
- org.springframework.web.client.HttpServerErrorException
- org.springframework.web.client.ResourceAccessException
- java.util.concurrent.TimeoutException
- java.io.IOException
ignoreExceptions: # 4xx is the caller's fault; never trips
- org.springframework.web.client.HttpClientErrorException
instances:
payments:
baseConfig: default
slidingWindowType: TIME_BASED # bursty traffic: look at the last 60 seconds
slidingWindowSize: 60
waitDurationInOpenState: 60s # give the provider longer to recover
inventory:
baseConfig: default # inherits everything; trips independently
recommendations:
baseConfig: default
slidingWindowSize: 20 # low traffic: smaller window and minimum
minimumNumberOfCalls: 10 # so the threshold is actually reachable
An application.yml with a shared default circuit breaker config and per-dependency instances that inherit it via baseConfig. The default records only 5xx, timeout and I/O exceptions and ignores 4xx client errors.
Sizing bulkheads and time limiters from the dependency's latency budget
Breakers react to failure rates. Bulkheads cap how much of your service a slow dependency can occupy before any failure rate is visible.
| Semaphore bulkhead | Thread pool bulkhead | |
|---|---|---|
| Executes on | Caller thread | Bounded pool plus queue |
| Return type | Any | CompletableFuture |
| Security context, MDC, trace context | Present automatically | Lost unless you register context propagators |
| Isolation | Caps concurrency only | Separate threads, so a hung call cannot pin a request thread |
Size the limit with Little's Law: concurrency = throughput × latency.
Illustrative estimate (assumed numbers, not a benchmark): a dependency receives 200 req/s, and its p99 latency budget is 0.15 s. 200 × 0.15 = 30 in-flight calls. Adding about 30% headroom gives maxConcurrentCalls: 40. Set maxWaitDuration near zero so callers that exceed the limit are rejected quickly instead of queueing. For a thread pool bulkhead, set maxThreadPoolSize to the same 40 and keep queueCapacity small. A deep queue adds latency without adding capacity.
The time limiter applies only to async calls. It bounds how long the caller waits, through timeoutDuration. With cancelRunningFuture: true it also calls cancel on the future. Cancellation only interrupts the thread, and most blocking socket reads ignore interrupts. The thread keeps waiting on the dependency, so the pool slowly fills with abandoned work. Configure the HTTP client's connect and read timeouts to fire at or just before timeoutDuration, for example a read timeout of 140 ms under a 150 ms limiter. The client timeout is what actually frees the thread. The limiter only stops the caller from waiting.
resilience4j:
# Semaphore bulkhead: runs on the caller thread, caps concurrency only.
bulkhead:
instances:
pricing:
maxConcurrentCalls: 40 # 200 req/s x 0.15 s = 30, plus ~30% headroom (illustrative estimate)
maxWaitDuration: 0ms # reject over-limit callers immediately instead of queueing
# Thread pool bulkhead: separate bounded pool, methods must return CompletableFuture.
thread-pool-bulkhead:
instances:
pricingAsync:
coreThreadPoolSize: 40
maxThreadPoolSize: 40 # same Little's Law figure as the semaphore limit
queueCapacity: 5 # small queue: a deep queue adds latency, not capacity
keepAliveDuration: 20ms
# Time limiter: async calls only. Bounds how long the caller waits.
timelimiter:
instances:
pricingAsync:
timeoutDuration: 150ms # the dependency's p99 latency budget
cancelRunningFuture: true # interrupts the worker thread, but a blocked socket read usually ignores it
Resilience4j Spring Boot configuration for a semaphore bulkhead, a thread pool bulkhead and a time limiter, all sized from the same illustrative 200 req/s × 0.15 s estimate, with a reminder that the HTTP client's own timeouts must fire just under the limiter's timeout.
Decorator order and fallbacks: what actually happens under failure
With stacked annotations, the Spring Boot integration applies the aspects in this order, outermost first:
Retry → CircuitBreaker → RateLimiter → TimeLimiter → Bulkhead → your method
This order has three consequences.
- Timeouts count as breaker failures. The
TimeoutExceptionthrown by the time limiter reaches the circuit breaker, which records it as a failure unless you exclude it. - Retries consume bulkhead permits. Every attempt passes through the bulkhead again and acquires a new permit. Three attempts against a saturated dependency hold three permits in sequence.
- Retries can count against the breaker multiple times. Each attempt goes through the breaker. With three attempts and a breaker that needs 10 minimum calls, roughly four failing user requests can open it. Choose retry count, exponential backoff and
minimumNumberOfCallstogether. Restrict retries to idempotent operations, because a retried POST that timed out may already have committed.
Also add CallNotPermittedException to the retry's ignored exceptions. Otherwise Retry keeps retrying against a breaker that is already open.
To change the order, set the per-module aspect order properties, such as resilience4j.retry.retryAspectOrder and resilience4j.circuitbreaker.circuitBreakerAspectOrder. A higher value means the aspect runs further out.
Fallbacks
A fallback method must sit in the same class and have the same return type. Its parameters must match the original method's, with an exception parameter added at the end. When several fallbacks exist, the one with the most specific matching exception type is called.
Write separate fallbacks for CallNotPermittedException and BulkheadFullException. An open breaker means the dependency is unhealthy. A full bulkhead means your own service is saturated.
Degrade gracefully only when stale or partial data is acceptable, such as recommendations or cached prices. For payments, fail fast. Whichever you choose, count and alert on fallback invocations. A fallback that only returns a default value can hide an outage indefinitely.
@Service
public class InventoryClient {
private static final Logger log = LoggerFactory.getLogger(InventoryClient.class);
private final InventoryApi api; // remote HTTP client for the inventory service
private final StockCache cache; // last known good values
public InventoryClient(InventoryApi api, StockCache cache) {
this.api = api;
this.cache = cache;
}
// Fallback sits on the outer aspect, so the breaker still records the failure first.
@CircuitBreaker(name = "inventory", fallbackMethod = "stockFallback")
@Bulkhead(name = "inventory")
public StockLevel getStock(String sku) {
return api.fetchStock(sku);
}
// Breaker open: the dependency is unhealthy. Degrade, but mark the data stale and log it.
private StockLevel stockFallback(String sku, CallNotPermittedException ex) {
log.warn("inventory breaker open, serving cached stock for {}", sku);
return cache.get(sku).map(StockLevel::markStale).orElseThrow(() -> ex);
}
// Bulkhead full: we are saturated, not the dependency. Fail fast so callers can shed load.
private StockLevel stockFallback(String sku, BulkheadFullException ex) {
log.warn("inventory bulkhead full, rejecting request for {}", sku);
throw new ServiceBusyException("inventory capacity exhausted", ex);
}
// Any other failure (timeouts, 5xx): most specific match wins, so this is the catch-all.
private StockLevel stockFallback(String sku, Exception ex) {
log.error("inventory call failed for {}", sku, ex);
throw new InventoryUnavailableException(sku, ex);
}
}
Overloaded fallback methods with the original parameters plus a trailing exception let an open breaker degrade to cached data while a full bulkhead fails fast, and both cases are logged so the outage stays visible.
Observing and testing resilience behavior
Metrics, health and state transitions
With the Actuator and Micrometer on the classpath, Resilience4j publishes meters such as resilience4j.circuitbreaker.state, resilience4j.circuitbreaker.failure.rate, resilience4j.circuitbreaker.slow.call.rate and resilience4j.bulkhead.available.concurrent.calls. Setting management.health.circuitbreakers.enabled=true adds breakers to the health endpoint. Keep them out of the liveness probe, though. An open breaker means a dependency is unhealthy, not that your pod is, and restarting the pod will not fix the dependency.
Metrics are sampled, so they can miss a short trip to OPEN. Register a listener with circuitBreaker.getEventPublisher().onStateTransition(...) and log every transition with the breaker name and the from and to states. That log is what you need for a post-incident timeline.
Alert on these signals:
- A breaker entering OPEN.
- Bulkhead rejections sustained over several minutes rather than single spikes.
- Slow call rate rising toward its threshold, which gives you warning before the breaker trips.
- Fallback invocations. Resilience4j has no built-in fallback meter, so increment your own Micrometer counter inside each fallback method.
Tests that prove the thresholds
For unit tests, build a CircuitBreakerRegistry with test configuration. Call transitionToOpenState() or transitionToForcedOpenState() and assert that the fallback runs and the downstream client is never called.
Unit tests do not show that your thresholds work against realistic responses. For that, run integration tests against an HTTP stub that adds fixed delays and returns 5xx responses. Assert that the breaker opens after the expected number of calls, and that the time limiter cancels calls at its configured timeout.
Alternatives, cost and engineering time
Resilience4j is one layer of protection, not the only one. Each option below protects a different boundary.
| Approach | Covers | Misses |
|---|---|---|
| HTTP client timeouts and connection pool limits only | Bounds each call and caps concurrent connections | No fast-fail once a dependency is known bad. No per-call fallback. Threads still wait for the full timeout. |
| Server-side protection and load shedding | Protects the dependency from overload by rejecting excess work | Does nothing for the caller's threads or latency. Rejected requests still cost a round trip. |
| Proxy or service mesh resilience | Uniform timeouts, retries and outlier ejection, with no application code | No access to business context. It cannot choose a cached value or a degraded response. Its retries can stack with application retries. |
| Resilience4j in the service | Per-dependency breakers, bulkheads and fallbacks tied to product logic | Configuration lives in every service and must be kept in sync |
The ongoing cost is in maintenance. Thresholds sized for today's traffic drift as call volume and dependency latency change. A breaker tuned for a quiet service can open too eagerly after a launch. Bulkhead limits also need re-checking whenever the thread model changes. Fallbacks need product review because they are product decisions: returning stale prices or an empty recommendation list changes what users see. The tests described above need to run for every protected dependency.
The payback comes during incidents. A slow dependency stays an isolated degradation instead of exhausting threads across the call chain. Breaker state and bulkhead rejection metrics also show which dependency failed, so diagnosis is faster than reading thread dumps.
Review thresholds against current latency percentiles on a fixed schedule, for example each quarter or after significant traffic changes. Treat an open breaker as a signal, not a fix. Client-side resilience contains the damage from a slow dependency, but the dependency itself still needs to be fixed.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to spring boot resilience4j, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.