How to Create a Conditional Loop with Mono in Java: Project Reactor

In Project Reactor, you can create a conditional loop with Mono to repeat an action until a condition is met. Using Mono.defer and recursive calls keeps it non-blocking and reactive. This way, your code runs efficiently without traditional loops.


  • Use .expand() — it handles recursive iteration natively without blocking the Netty thread.
  • Chain .takeLast(1) after .expand() — you get the final state, not the entire intermediate stream.
  • Use recursive flatMap when you need tight control over state mutation across iterations.
  • Avoid .repeat() for stateful loops — it re-subscribes to the source, not to the accumulated state.

If youre building a reactive pipeline in Project Reactor and you need to keep iterating while a condition is true — polling an external API, retrying a computation, walking a paginated result — you cant just drop a conditional loop with Mono into a flatMap and call it a day. A standard while loop blocks the thread it runs on, and in a Netty-based runtime that means youre starving the event loop. The fix isnt a workaround — its three legitimate operators: .expand(), recursive flatMap, and .repeat(). Each has a specific use case, and picking the wrong one costs you either correctness or performance.

// DON'T do this in a reactive pipeline
Mono.fromCallable(() -> {
    State s = fetchInitialState();
    while (!s.isDone()) {       // blocks the Netty thread
        s = processNext(s);
    }
    return s;
});

This compiles and even works in unit tests — until it hits production load and your Netty thread pool saturates at 2× CPU cores.

Why a Blocking While Loop Destroys WebFlux Performance

Project Reactor runs on a scheduler backed by a small, fixed thread pool — typically Schedulers.parallel() with one thread per CPU core. Netty, which powers Spring WebFlux, operates on the same event-loop model. When you block one of those threads with a standard Java while(condition), youre not sleeping — youre occupying a thread that could be processing thousands of other reactive chains. Ive seen a single blocked thread drop throughput by 30% on a 4-core box under moderate load.

The event loop expects non-blocking callbacks. A while loop gives it a synchronous wall. Every millisecond that thread is spinning inside your loop is a millisecond it cant dispatch new events, handle I/O completions, or schedule pending Mono continuations. This is thread starvation — and it compounds fast under concurrency. Project Reactor loop without blocking threads isnt a nice-to-have; its the contract you agreed to the moment you chose a reactive stack.

The mental model shift is simple: instead of loop until done, think emit the next state, check the condition, stop if false. Thats exactly what .expand() does.

Related materials
Clean Code is Killing...

Abstraction Inflation: Why Your Clean Code is Killing the Project There is a specific stage in a developer's journey—usually somewhere between the second and fourth year—where they become dangerous. They’ve read "Design Patterns," they’ve watched...

[read more →]

Solution 1: .expand() — The Clean Recursive Approach

The .expand() operator is the idiomatic Project Reactor answer to recursive iteration. It takes the current emitted value, applies a mapping function that returns the next Publisher, and keeps going until that publisher completes empty. No manual recursion, no stack games — Reactor handles the traversal internally using a breadth-first expansion strategy.

Mono.just(new State(0, false))
    .expand(state -> state.isDone()
        ? Mono.empty()
        : Mono.fromCallable(() -> processNext(state)))
    .takeLast(1) // .expand() returns a Flux. We take the last emitted item...
    .next()      // ...and convert it back to a Mono for the final result.
    .subscribe(finalState -> System.out.println("Done: " + finalState));

The .takeLast(1) is non-negotiable here — without it, you get every intermediate state emitted during the expansion, not just the final one.

How .expand() Iterates Until Condition Is False

When state.isDone() returns true, the lambda returns Mono.empty(), which signals the operator to stop expanding. Reactor doesnt recurse into the JVM call stack — it queues the next expansion as a scheduler task, which keeps the event loop free. The Project Reactor Mono expand example above is safe under any iteration count because the depth is never reflected in stack frames. You can run 10,000 iterations without a StackOverflowError, which is something recursive flatMap cant guarantee without an explicit trampoline.

Related materials
V8: Deterministic Engine Architecture

V8 Engine Internal Architecture: Achieving Deterministic JavaScript Execution JavaScript is often treated as a “magic” language: write code, press run, and it works. But in high-throughput applications like trading dashboards, real-time analytics, or browser-based audio...

[read more →]

Solution 2: Recursive flatMap — Control Over State Mutation

Sometimes .expand() is too opaque. You need to thread additional context through each iteration — an accumulator, a counter, a mutable builder — and you want the control flow to be explicit in your code. Thats where recursive flatMap earns its place. The pattern is straightforward: write a method that returns a Mono, and inside that method call itself conditionally inside a flatMap.

private Mono<State> iterateUntilDone(State current) {
    if (current.isDone()) {
        return Mono.just(current);
    }
    return Mono.fromCallable(() -> processNext(current))
               .flatMap(next -> iterateUntilDone(next));
}

Each call to iterateUntilDone returns immediately — the actual work is deferred into a Mono chain that Reactor schedules asynchronously.

Stack Safety and What Reactor Does Under the Hood

This is where people get burned. Recursive flatMap in Project Reactor is not automatically stack-safe. If your processNext returns synchronously — for example, Mono.just(nextState) instead of Mono.fromCallable(...) — Reactor may fuse the operators and execute them synchronously on the same stack frame. Enough iterations and you hit a StackOverflowError. The fix is to ensure each recursive step introduces a genuine async boundary: wrap the call in Mono.defer() or use subscribeOn(Schedulers.boundedElastic()) to force a scheduler hop. Reactors Project Reactor Mono loop while condition pattern is sound — but it requires that each step genuinely yields.

private Mono iterateSafe(State current) {
    if (current.isDone()) return Mono.just(current);
    return Mono.defer(() ->
        Mono.fromCallable(() -> processNext(current))
            .flatMap(next -> iterateSafe(next))
    );
}

Mono.defer() wraps each recursive step in a lazy factory — Reactor cant fuse across defer boundaries, so each iteration gets scheduled separately and the JVM stack stays shallow.

Solution 3: .repeat() — Why Devs Reach for It and Get Burned

The .repeat() operator is the first thing most developers try when they need a loop in Project Reactor. Its obvious, its short, and it does exactly what the name says: repeat the subscription. The problem is what re-subscribe actually means — it goes back to the source, not to the accumulated state. If your source is a database call or an HTTP request that returns the initial value, .repeat() starts over from scratch every time. For stateless polling this is fine. For anything that builds state across iterations, its a trap.

AtomicReference stateRef = new AtomicReference<>(new State(0, false));

Mono.fromCallable(() -> stateRef.updateAndGet(s -> processNext(s)))
    .repeat(() -> !stateRef.get().isDone())
    .takeLast(1)
    .next()
    .subscribe(s -> System.out.println("Final: " + s));

This works, but youre managing state externally via AtomicReference — which is boilerplate .expand() eliminates entirely.

Spring WebFlux repeat vs expand: What Actually Differs

The architectural difference matters at scale. repeat() re-subscribes to the upstream publisher on each iteration — if that publisher has side effects on subscription (like opening a connection or resetting a cursor), you get those side effects every loop. expand() works on the emitted value, not on the source publisher. It never re-subscribes; it just asks: given this value, whats next? That model is safer, more predictable, and pairs better with immutable state.

Implementing a logic to stop a Mono repeat on a condition is also less clean: you need a BooleanSupplier that reads from external state, whereas expand() stops naturally when you return Mono.empty(). Spring WebFlux Mono repeat until a condition is useful for retrying idempotent sources, not for accumulating state across iterations.

Best Practices: When to Use Which Operator

After shipping reactive pipelines in production for a while, the decision tree becomes second nature. Use .expand() as your default for any loop where the next state is derived from the current emitted value — pagination, recursive tree traversal, polling with backoff. Its the cleanest model and Reactor handles the scheduling details for you. Use recursive flatMap when you need fine-grained control: custom accumulators, complex branching, or when you want the loop logic to read explicitly in code rather than being implicit in operator behavior. Add Mono.defer() if the recursive steps are synchronous — non-negotiable.

Related materials
Data Oriented Design Performance...

The Silicon Ceiling: Engineering for Data Oriented Design Performance Modern software development has a massive blind spot: we are still writing code for processors that existed twenty years ago. We obsess over O(n) algorithmic complexity...

[read more →]

Reserve .repeat() for stateless or externally-managed state scenarios. If you find yourself reaching for an AtomicReference to make .repeat() work with state, thats a signal to switch to .expand(). And regardless of which operator you choose, never block inside the chain — no Thread.sleep(), no synchronous I/O, no while loops inside flatMap. If you need to call blocking code, wrap it in Mono.fromCallable() and push it to Schedulers.boundedElastic(). Writing code that just works in a reactive context means understanding the scheduler model, not just making the compiler happy.


FAQ

Can I use a for loop inside a Mono flatMap safely?

Only if every iteration is synchronous and the total execution time is negligible. For anything non-trivial, a for loop inside flatMap occupies the scheduler thread for its entire duration — the same problem as a while loop. Use Flux.fromIterable() with flatMap instead to process iterations reactively.

How does Project Reactor handle stack overflow in recursive flatMap?

It doesnt — not automatically. Reactor only guarantees stack-safety for operators that explicitly support it, like expand(). For recursive flatMap, you must introduce async boundaries via Mono.defer() or a scheduler hop to prevent deep stack frames from accumulating.

What is the difference between repeat and retry in Project Reactor?

.retry() re-subscribes on error signals; .repeat() re-subscribes on completion signals. Neither accumulates state between subscriptions — they both restart from the source. For iterating over Mono until condition is false, neither is the right tool if state needs to persist across iterations.

Is Mono expand breadth-first or depth-first?

Breadth-first by default. Reactor processes all values at the current expansion level before moving to the next. For linear iteration — where each step produces exactly one next value — this distinction doesnt matter, but for tree traversal it affects the emission order significantly.

How do I add a delay between iterations in a reactive loop?

Chain Mono.delay(Duration.ofMillis(n)) inside the .expand() lambda before returning the next state. This defers the next iteration without blocking any thread — Reactor schedules the delay on its timer and releases the event loop thread immediately.

Can reactive programming while loop Java work with Spring WebFlux controllers?

Yes — return the final Mono from your controller method directly. Spring WebFlux subscribes to it asynchronously; the loop runs entirely on the reactor scheduler without touching the HTTP handler thread. Just make sure you chain .takeLast(1).next() so the controller receives a single value, not a stream.


Written by: