Writing

Mono.just Runs Too Early: Nothing Happens Until You Subscribe, Except When It Does

Reactor is lazy, but Java is not: whatever you pass to Mono.just has already run. Where the trap hides, what it breaks, and how to prove in a test that your code waits for the subscription

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 an onError signal, and the operators downstream see it.
  • Mono.defer(() -> buildAMono()) calls the supplier at each subscription and subscribes to the Mono it 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.just and Flux.just take 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.defer or Flux.defer.
  • A blocking call: Mono.fromCallable(...) then subscribeOn(Schedulers.elastic()), never Mono.just(...).
  • Fallbacks in switchIfEmpty and onErrorResume are built lazily, or they run on every call.
  • retry and repeat replay 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.