Defer vs Immediate Reactive Flow Creation (and why your Circuit Breaker can “decide too early”)
Source: Dev.to
Introduction
If you’ve ever looked at a circuit‑breaker log and wondered “why did it allow/block this call?” the problem is often not the breaker itself. It’s the moment when your reactive pipeline is created versus when it is executed.
Reactive streams are lazy (execution happens on subscribe()), but not every decision in your code is automatically delayed. That’s where Mono.defer / Flux.defer becomes a lifesaver: you can assemble the pipeline now, while ensuring that the actual work (HTTP call, DB query, etc.) happens later, at subscription time.
The “too early” bug
A circuit breaker works like this:
- CLOSED → allow the call
- OPEN → block the call (throw / short‑circuit / fallback)
If the breaker decision is taken at pipeline creation time, it may be “too early”. If it’s taken at subscription time, the decision reflects the real state of the external service.
When you reuse a Mono/Flux, delay subscription, or have multiple subscribers, “too early” becomes a real bug.
Example that bites people
import reactor.core.publisher.Mono;
import java.time.Duration;
class PaymentClient {
Mono charge(String userId) {
// Imagine this is an HTTP call
return Mono.delay(Duration.ofMillis(100))
.thenReturn("charged:" + userId);
}
}
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import java.time.Duration;
class Breakers {
static CircuitBreaker paymentsBreaker() {
var config = CircuitBreakerConfig.custom()
.failureRateThreshold(50)
.slidingWindowSize(10)
.waitDurationInOpenState(Duration.ofSeconds(5))
.build();
return CircuitBreaker.of("payments", config);
}
}
Bad pattern – reusing a Mono
import io.github.resilience4j.reactor.circuitbreaker.operator.CircuitBreakerOperator;
import reactor.core.publisher.Mono;
class PaymentServiceBad {
private final PaymentClient client = new PaymentClient();
private final CircuitBreaker cb = Breakers.paymentsBreaker();
// Someone caches this Mono or reuses it across requests
Mono buildChargeFlow(String userId) {
Mono flow = client.charge(userId)
.transformDeferred(CircuitBreakerOperator.of(cb)); // breaker applied here
return flow;
}
}
Because flow is just an object, it may be:
- stored in a cache layer
- reused by multiple subscribers
- subscribed later after additional pipeline composition
If the circuit‑breaker state changes between build and subscribe, you get inconsistent behavior. Some operators also capture context at assembly time, so the breaker decision and dynamic values are evaluated too early.
Correct approach – defer the creation
import io.github.resilience4j.reactor.circuitbreaker.operator.CircuitBreakerOperator;
import reactor.core.publisher.Mono;
class PaymentServiceGood {
private final PaymentClient client = new PaymentClient();
private final CircuitBreaker cb = Breakers.paymentsBreaker();
Mono charge(String userId) {
return Mono.defer(() ->
client.charge(userId)
.transformDeferred(CircuitBreakerOperator.of(cb))
);
}
}
- ✅ The circuit‑breaker permission check happens when someone subscribes.
- Each subscription gets a fresh pipeline, so the decision aligns with the actual call time, retries, background reprocesses, or multiple consumers.
Demonstration
var service = new PaymentServiceGood();
// Subscriber A
service.charge("user-1")
.doOnNext(System.out::println)
.subscribe();
// Subscriber B (later)
service.charge("user-1")
.doOnNext(System.out::println)
.subscribe();
If the circuit flips to OPEN between A and B, B will be blocked as expected. Without defer (or when reusing the same Mono instance), you might unintentionally carry stale timing/state.
When to use defer
- The flow might be subscribed more than once.
- The flow might be created now but subscribed later.
- You need circuit‑breaker decisions aligned with the actual call time.
- Accurate metrics/logs per attempt are important.
When you can skip defer
- The flow is always subscribed immediately.
- You intentionally want to evaluate once and reuse the exact same pipeline instance.
Conclusion
In reactive programming, timing is part of the logic. Mono.defer / Flux.defer lets you say:
“I want this decision to be made when it actually matters.”
When circuit breakers are involved, that timing often makes the difference between “works fine” and “why is this thing gaslighting me”.