Every introduction to Reactor repeats the same rule: nothing happens until you subscribe. It is true, and it is the first thing a team moving from Spring MVC to WebFlux learns. It is also the rule that makes the most common reactive bug so hard to see, because one of the first methods everyone calls breaks it without warning.
Here is a method that looks safe:
public Mono<Integer> ratio(int a, int b) {
return Mono.just(divide(a, b))
.onErrorResume(e -> Mono.just(0));
}
The intent is clear: divide, and fall back to zero if anything goes wrong. Call
ratio(1, 0) and the fallback never runs. The ArithmeticException is thrown
by the method itself, before any Mono exists, and the request fails with an
error instead of the zero the code promised.
Reactor did nothing wrong. Java did exactly what Java always does.
Two moments, not one
A reactive chain lives through two moments, and the whole subject comes down to knowing which one your code runs in.
The first is assembly. When ratio returns, it has built a description of
work: a source, then an operator, then another. No division has been computed by
Reactor, no thread has been borrowed, no signal has been sent. The chain is a
recipe.
The second is subscription. When someone subscribes, the recipe runs. The
publisher sends onSubscribe, then zero or more onNext signals, then at most
one terminal signal: onComplete when the sequence ends normally, or onError
when it fails, never both. A sequence that is cancelled, or never ends, sends
neither. A Mono sends at most one onNext, a Flux as many as it has.

Operators such as map, filter and onErrorResume respect that split. The
lambda you give them is stored at assembly and called at subscription. That is
what makes the rule true.
The argument has already run
Mono.just does not take a lambda. It takes a value. And in Java, the arguments
of a method are evaluated before the method is called. When the JVM reaches
Mono.just(divide(a, b)), it runs divide(a, b) first, at assembly, on the
calling thread, and only then hands the result to Reactor.
The Reactor reference guide says it plainly: just “directly captures the value
at assembly time and replays it to anybody subscribing to it later”. The javadoc
says the same in fewer words: the item “is captured at instantiation time”.
Nothing about it is lazy.
Reactor has two factories for work that must wait for the subscription:
Mono.fromCallable(() -> divide(a, b))runs the callable when someone subscribes. If it throws, the exception becomes anonErrorsignal, and the operators downstream see it.Mono.defer(() -> buildAMono())calls the supplier at each subscription and subscribes to theMonoit returns. Use it when what you produce is itself a publisher.
The fixed method reads almost the same:
public Mono<Integer> ratio(int a, int b) {
return Mono.fromCallable(() -> divide(a, b))
.onErrorResume(e -> Mono.just(0));
}
ratio(1, 0) now returns a Mono that emits 0. The division happens inside
the chain, so its failure travels through the chain.

A Java 8 developer already knows this shape. A Stream does nothing until its
terminal operation, yet Stream.of(compute()) still runs compute() before the
stream exists. Laziness belongs to the pipeline, never to the arguments you pass
to build it. Reactive programming is easier to learn after functional
programming for exactly that reason: the questions are the same, only the stakes
are higher.
What the early call breaks
A thrown exception is the visible case. Three others hide better, and they are the ones that reach production.
Error handling. Every error operator (onErrorResume, onErrorReturn,
doOnError) only sees errors that travel as signals. An exception thrown during
assembly leaves the method like any other exception. The fallback is dead code,
and nothing in a code review shows it.
Threading. WebFlux runs on a small number of event-loop threads, and blocking one of them stalls every request it serves. The usual answer to a blocking call is to move it to a scheduler built for that:
Mono.just(repository.load(id))
.subscribeOn(Schedulers.elastic());
This moves nothing. subscribeOn changes the thread on which the subscription
happens, but repository.load(id) has already run, on the event loop, before
subscribeOn was even called. The pattern the Reactor documentation gives for
wrapping a blocking call is the lazy one:
Mono.fromCallable(() -> repository.load(id))
.subscribeOn(Schedulers.elastic());
Now the load runs at subscription, on a thread of the elastic scheduler, and the event loop stays free.
Retry. retry works, in the guide’s words, “by re-subscribing” to the
upstream sequence. Re-subscribing to Mono.just replays the same captured
value. Consider a remote call that needs a short-lived token:
Mono.just(tokenProvider.current())
.flatMap(token -> client.call(token))
.retry(3);
If the token has expired, the call fails, and each of the three retries sends the same expired token again. The retry policy looks right, yet it can never succeed. Deferring the token makes each attempt fetch a fresh one:
Mono.defer(() -> Mono.just(tokenProvider.current()))
.flatMap(token -> client.call(token))
.retry(3);
The same trap in other guises
Once you start looking for method calls inside arguments, you find them everywhere.
A fallback built eagerly runs on every call, even when it is not needed:
userRepository.findById(id)
.switchIfEmpty(Mono.just(loadDefaultUser()));
loadDefaultUser() runs each time this line is assembled, whether the user
exists or not. If it queries a database, every successful lookup pays for a
query it throws away. The lazy version only pays when the source is empty:
userRepository.findById(id)
.switchIfEmpty(Mono.fromCallable(() -> loadDefaultUser()));
A list built in advance loads everything before anyone asks:
Flux.fromIterable(repository.findAll());
The whole list is in memory at assembly, even if the subscriber takes the first
ten items and cancels.
Flux.defer(() -> Flux.fromIterable(repository.findAll())) at least waits for a
subscriber; a source that streams its rows is better still.
Let the framework subscribe
The other half of the rule is who subscribes. In a WebFlux controller, the answer is never you.
@GetMapping("/ratio")
public Mono<Integer> ratio(@RequestParam int a, @RequestParam int b) {
return service.ratio(a, b);
}
The controller returns the Mono, and WebFlux subscribes to it when it writes
the response, with the request’s context and its error handling. Calling
subscribe() inside the controller starts a second, independent execution that
nobody waits for: its result is lost, and an error with no handler surfaces as
an ErrorCallbackNotImplemented in a log, far from the request that caused it.
The same holds for a service method: return the publisher, let the caller
compose it.
Prove the laziness in a test
Reactor ships a test tool for exactly this, StepVerifier, in the
reactor-test module. It subscribes to a publisher, checks each signal in
order, and only runs when you call verify() or a shortcut that calls it, such
as verifyComplete().
The first test proves the fallback works:
@Test
public void divisionByZeroFallsBackToZero() {
StepVerifier.create(service.ratio(1, 0))
.expectNext(0)
.verifyComplete();
}
With the Mono.just version, this test does not fail on an assertion. It fails
with an ArithmeticException thrown from service.ratio, before StepVerifier
has anything to verify: the whole bug, in one line.
The second test proves that nothing runs before the subscription:
@Test
public void nothingRunsBeforeSubscription() {
AtomicInteger calls = new AtomicInteger();
Mono<Integer> ratio = Mono.fromCallable(() -> {
calls.incrementAndGet();
return 10 / 2;
});
assertEquals(0, calls.get());
StepVerifier.create(ratio)
.expectNext(5)
.verifyComplete();
assertEquals(1, calls.get());
}
A counter that has not moved after assembly is the cheapest proof that a method
respects the two moments. Move the callable’s body into a method, pass its
result to Mono.just, and the first assertion fails: the counter has already
moved.
A checklist for code review
Mono.justandFlux.justtake values that already exist: constants, method parameters, results you already hold. A method call inside their parentheses is the smell to look for.- One value to compute:
Mono.fromCallable. A publisher to build:Mono.deferorFlux.defer. - A blocking call:
Mono.fromCallable(...)thensubscribeOn(Schedulers.elastic()), neverMono.just(...). - Fallbacks in
switchIfEmptyandonErrorResumeare built lazily, or they run on every call. retryandrepeatreplay the source: defer whatever must be computed again at each attempt.- Controllers and services return publishers; the framework subscribes.
- One test with a counter proves the laziness, and stays green only as long as the code is lazy.
Nothing happens until you subscribe. The rule holds for everything Reactor runs, and for nothing Java runs before Reactor gets the chance.