Reactive Programming with Project Reactor: Mono and Flux

Introduction

This tutorial starts the Reactive Spring module with the library underneath it: Project Reactor, and its two types Mono and Flux. We will see what problem reactive programming solves, the four interfaces of the Reactive Streams specification and the signals they exchange, and why nothing happens until something subscribes. Then we will create Monos and Fluxes, transform them with operators, compare flatMap with concatMap, handle errors without try-catch, move work between threads with publishOn and subscribeOn, and let a slow subscriber control the pace with backpressure. All code is plain Java with Reactor 3.8, and every output below comes from running it.

Why reactive programming

A classic Spring MVC application handles each request on one thread from start to end. When the request waits - for a database, another service, a file - the thread waits with it, doing nothing, but still occupying its memory and its slot in the pool. With 200 threads and calls that each wait 200 ms, the server handles at most 1000 requests per second, however idle the CPU is. Reactive programming avoids the waiting thread. Instead of "call, wait, use the result", the code says "call, and when the result arrives, do this with it". The I/O is non-blocking: the thread hands it over and moves on to other work, and a few threads - often one per CPU core - serve thousands of concurrent requests. The price is a different programming style: the code describes a pipeline that data will flow through later, rather than steps that run now. Blocking - each request holds a thread while it waits for the database; non-blocking - a few event-loop threads start the I/O, serve other requests while it runs, and continue when the result arrives Java 21's virtual threads attack the same problem from the other side - cheap threads that may block - and Virtual Threads vs WebFluxClick to read tutorial: Virtual Threads vs WebFlux: Which to Choose in 2026 compares the two. Reactive streams also bring something virtual threads do not: a common way to express streams of data with timing, combination and backpressure.

Reactive Streams: four interfaces and four signals

Reactor implements the Reactive Streams specification, which Java also contains as java.util.concurrent.Flow. It has four interfaces -
  • Publisher<T>: a source of data, with one method, subscribe(Subscriber). Mono and Flux are publishers.
  • Subscriber<T>: receives the signals onSubscribe, onNext (one per element), and finally onComplete or onError.
  • Subscription: the link between the two. The subscriber calls request(n) on it to say how many more elements it can take - this is backpressure - or cancel().
  • Processor<T, R>: both at once, a stage in the middle.
The order of the signals is fixed: one onSubscribe, then any number of onNext - never more than requested - and then at most one terminal signal, onComplete or onError. After a terminal signal, nothing more arrives. The Reactive Streams protocol - subscribe, onSubscribe with a Subscription, request(n), up to n onNext signals, and a final onComplete or onError

Mono and Flux

Reactor's two publishers differ in how many elements they emit -
  • Mono<T>: 0 or 1 element, then completion - or an error. The reactive counterpart of a method returning a value or Optional: one book by id, the result of a save.
  • Flux<T>: 0 to N elements - possibly infinite - then completion or an error. The counterpart of a list or a stream: all books, a stream of price updates.
Marble diagrams - a Flux emits several elements over time and then completes; a Mono emits at most one element and completes; an error ends either stream The first program creates a few of each, and shows the most important rule of reactive code: nothing happens until someone subscribes -
CreatingStreams.java
//CreatingStreams.java
package com.javabrahman.reactive.basics;

import java.util.List;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

public class CreatingStreams {

  static String load(String isbn) {
    System.out.println("   loading " + isbn + " from the database");
    return "Effective Java";
  }

  public static void main(String[] args) {
    Mono<String> fixed = Mono.just("Domain-Driven Design");               // a value that exists already
    Mono<String> lazy = Mono.fromSupplier(() -> load("111"));   // computed on subscription
    Flux<Integer> numbers = Flux.range(1, 5);
    Flux<String> titles = Flux.fromIterable(List.of("Refactoring", "Clean Code"));
    Mono<String> nothing = Mono.empty();
    Flux<String> failing = Flux.error(new IllegalStateException("disk full"));

    System.out.println("1. pipelines created, nothing has run yet");

    System.out.println("2. subscribing to lazy:");
    lazy.subscribe(title -> System.out.println("   got " + title));
    System.out.println("   and again - the supplier runs once per subscriber:");
    lazy.subscribe(title -> System.out.println("   got " + title));

    System.out.println("3. a Flux, with handlers for all three kinds of signal:");
    numbers.map(n -> n * n).subscribe(
        n -> System.out.println("   onNext " + n),
        error -> System.out.println("   onError " + error),
        () -> System.out.println("   onComplete"));

    System.out.println("4. empty, an error, and a fixed value:");
    nothing.subscribe(t -> System.out.println("   never printed"), e -> { }, () -> System.out.println("   empty: onComplete only"));
    failing.subscribe(t -> { }, e -> System.out.println("   failing: onError " + e.getMessage()));
    titles.concatWith(fixed).subscribe(t -> System.out.println("   " + t));
  }
}
 OUTPUT of the above code
1. pipelines created, nothing has run yet
2. subscribing to lazy:
   loading 111 from the database
   got Effective Java
   and again - the supplier runs once per subscriber:
   loading 111 from the database
   got Effective Java
3. a Flux, with handlers for all three kinds of signal:
   onNext 1
   onNext 4
   onNext 9
   onNext 16
   onNext 25
   onComplete
4. empty, an error, and a fixed value:
   empty: onComplete only
   failing: onError disk full
   Refactoring
   Clean Code
   Domain-Driven Design
  • Creating lazy did not call load(); each subscription did. A Mono or Flux is a recipe, not a result: every subscriber runs the pipeline again from the start. Such publishers are called cold.
  • Mono.just() is the exception that catches people out: its argument is evaluated when the Mono is created, by plain Java rules. Mono.just(load("111")) would load at once, subscribed or not; fromSupplier(), fromCallable() and defer() wait for a subscriber.
  • An empty publisher sends only onComplete, and an error ends the stream with onError - errors are signals, not thrown exceptions.
In a Spring WebFlux application you rarely call subscribe() yourself: a controller returns the Mono or Flux, and the framework subscribes when it writes the response.

Operators

Operators turn a publisher into a new publisher - much like the methods of Java's Stream API, but over time. The common ones -
Operators.java
//Operators.java
package com.javabrahman.reactive.basics;

import java.time.Duration;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

public class Operators {

  // stands for a non-blocking call that answers after some time
  static Flux<String> reviewsOf(String book, int delayMillis) {
    return Flux.just(book + " review 1", book + " review 2").delayElements(Duration.ofMillis(delayMillis));
  }

  public static void main(String[] args) {
    System.out.println("map, filter, take, collectList:");
    Flux.range(1, 10)
        .filter(n -> n % 2 == 0)
        .map(n -> "#" + n)
        .take(3)
        .collectList()                          // Flux<String> -> Mono<List<String>>
        .subscribe(list -> System.out.println("   " + list));

    System.out.println("flatMap - inner publishers run at the same time, results interleave:");
    Flux.just("A", "B")
        .flatMap(book -> reviewsOf(book, book.equals("A") ? 300 : 100))
        .doOnNext(r -> System.out.println("   " + r))
        .blockLast();                           // wait - only acceptable in a main method or a test

    System.out.println("concatMap - one inner publisher after the other, in order:");
    Flux.just("A", "B")
        .concatMap(book -> reviewsOf(book, book.equals("A") ? 300 : 100))
        .doOnNext(r -> System.out.println("   " + r))
        .blockLast();

    System.out.println("zip - combine the results of independent calls:");
    Mono<String> title = Mono.just("Effective Java").delayElement(Duration.ofMillis(200));
    Mono<Integer> price = Mono.just(450).delayElement(Duration.ofMillis(200));
    long start = System.nanoTime();
    String combined = Mono.zip(title, price, (t, p) -> t + " costs Rs. " + p).block();
    System.out.println("   " + combined + ", after about " + Math.round((System.nanoTime() - start) / 100_000_000.0) * 100 + " ms");
  }
}
 OUTPUT of the above code
map, filter, take, collectList:
   [#2, #4, #6]
flatMap - inner publishers run at the same time, results interleave:
   B review 1
   B review 2
   A review 1
   A review 2
concatMap - one inner publisher after the other, in order:
   A review 1
   A review 2
   B review 1
   B review 2
zip - combine the results of independent calls:
   Effective Java costs Rs. 450, after about 200 ms
  • map transforms each element synchronously, with a plain function. flatMap transforms each element into a new publisher - another non-blocking call - and merges the results. It is the operator for "for each order, call the payment service".
  • flatMap subscribed to the reviews of A and B at once. B's were faster, so they came first: the results interleaved, in order of arrival. concatMap waited for A's reviews to complete before starting B's: the order was kept, at the cost of the waiting.
  • zip waited for both values, which were fetched at the same time: 200 ms in all, not 400.
  • delayElements and delayElement run on Reactor's timer threads, so the main method had to wait for the results with blockLast() or block(). In application code, block() throws away everything reactive programming gains - and on Reactor's own threads it throws an exception.
flatMap subscribes to both inner publishers at once and emits results as they arrive, interleaved; concatMap waits for the first inner publisher to complete before subscribing to the second

Errors are signals

An exception thrown inside an operator, or an error from a publisher, travels down the pipeline as an onError signal and ends the stream. Operators take the place of try-catch -
Errors.java
//Errors.java
package com.javabrahman.reactive.basics;

import java.util.concurrent.atomic.AtomicInteger;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

public class Errors {

  public static void main(String[] args) {
    System.out.println("an exception in map ends the stream:");
    Flux.just(10, 5, 0, 2)
        .map(n -> 100 / n)
        .subscribe(n -> System.out.println("   " + n), e -> System.out.println("   onError " + e));

    System.out.println("onErrorReturn - a fallback value, then completion:");
    Flux.just(10, 5, 0, 2)
        .map(n -> 100 / n)
        .onErrorReturn(-1)
        .subscribe(n -> System.out.println("   " + n), e -> { }, () -> System.out.println("   onComplete"));

    System.out.println("onErrorResume - a fallback publisher, chosen by the error:");
    Mono.<String>error(new IllegalStateException("price service down"))
        .onErrorResume(IllegalStateException.class, e -> Mono.just("price from the cache"))
        .subscribe(s -> System.out.println("   " + s));

    System.out.println("onErrorMap - translate the exception:");
    Mono.error(new IllegalArgumentException("bad isbn"))
        .onErrorMap(e -> new IllegalStateException("lookup failed: " + e.getMessage()))
        .subscribe(s -> { }, e -> System.out.println("   " + e));

    System.out.println("retry - subscribe again, here up to 2 more times:");
    AtomicInteger attempts = new AtomicInteger();
    Mono.fromCallable(() -> {
          int n = attempts.incrementAndGet();
          System.out.println("   attempt " + n);
          if (n < 3) {
            throw new IllegalStateException("timeout");
          }
          return "rate 88.5";
        })
        .retry(2)
        .doFinally(signal -> System.out.println("   doFinally: " + signal))
        .subscribe(s -> System.out.println("   " + s));
  }
}
 OUTPUT of the above code
an exception in map ends the stream:
   10
   20
   onError java.lang.ArithmeticException: / by zero
onErrorReturn - a fallback value, then completion:
   10
   20
   -1
   onComplete
onErrorResume - a fallback publisher, chosen by the error:
   price from the cache
onErrorMap - translate the exception:
   java.lang.IllegalStateException: lookup failed: bad isbn
retry - subscribe again, here up to 2 more times:
   attempt 1
   attempt 2
   attempt 3
   rate 88.5
   doFinally: onComplete
The division by zero ended the first stream after two elements: the 2 was never processed. onErrorReturn replaced the error with a value - but the stream still ended there. retry() works because publishers are cold: retrying means subscribing again, which runs the call again. For real retries, retryWhen(Retry.backoff(3, Duration.ofMillis(100))) adds growing delays between the attempts. doFinally runs after any terminal signal or a cancellation, like a finally block.

Threads and schedulers

Reactor does not create threads by itself. A pipeline runs on the thread that subscribes - or, for timed operators like delayElements, on Reactor's timer threads - until an operator switches. Two operators switch on purpose, using a scheduler -
  • publishOn(scheduler): the operators after it run on the scheduler's threads.
  • subscribeOn(scheduler): the subscription, and so the source, run on the scheduler's threads, wherever in the chain the operator stands.
  • Schedulers.parallel(): one thread per CPU core, for CPU work. Schedulers.boundedElastic(): a growing, capped pool for blocking calls that cannot be avoided, such as a JDBC query or a legacy client.
Threads.java
//Threads.java
package com.javabrahman.reactive.basics;

import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;

public class Threads {

  static String where(String step) {
    return "   " + step + " on " + Thread.currentThread().getName().replaceAll("-\\d+$", "-N");
  }

  // a blocking call, such as JDBC: must not run on a reactive thread
  static String blockingLookup() throws InterruptedException {
    System.out.println(where("blocking lookup"));
    Thread.sleep(100);
    return "Effective Java";
  }

  public static void main(String[] args) {
    System.out.println("no scheduler - everything on the subscribing thread:");
    Flux.range(1, 1).map(n -> where("map")).subscribe(System.out::println);

    System.out.println("publishOn - switches for the operators after it:");
    Flux.range(1, 1)
        .map(n -> where("map before publishOn"))
        .doOnNext(System.out::println)
        .publishOn(Schedulers.parallel())
        .map(s -> where("map after publishOn"))
        .doOnNext(System.out::println)
        .blockLast();

    System.out.println("subscribeOn - moves the source, wherever it stands:");
    Mono.fromCallable(Threads::blockingLookup)
        .map(title -> where("map"))
        .subscribeOn(Schedulers.boundedElastic())
        .doOnNext(System.out::println)
        .block();
  }
}
 OUTPUT of the above code
no scheduler - everything on the subscribing thread:
   map on main
publishOn - switches for the operators after it:
   map before publishOn on main
   map after publishOn on parallel-N
subscribeOn - moves the source, wherever it stands:
   blocking lookup on boundedElastic-N
   map on boundedElastic-N
The thread numbers are replaced by N, as they differ from run to run. The last pipeline is the standard way to wrap a blocking call: Mono.fromCallable(...) with subscribeOn(Schedulers.boundedElastic()), so that it never runs on - and never blocks - one of the few event-loop threads of a WebFlux server.

Backpressure

A subscriber that cannot keep up tells the publisher how much it can take, with request(n). The lambda-based subscribe() requests an unbounded amount; a BaseSubscriber can request in small batches. doOnRequest shows each request travelling upstream -
Backpressure.java
//Backpressure.java
package com.javabrahman.reactive.basics;

import org.reactivestreams.Subscription;
import reactor.core.publisher.BaseSubscriber;
import reactor.core.publisher.Flux;

public class Backpressure {

  public static void main(String[] args) {
    System.out.println("a subscriber that takes 2 elements at a time:");
    Flux.range(1, 5)
        .doOnRequest(n -> System.out.println("   request(" + n + ")"))
        .subscribe(new BaseSubscriber<Integer>() {
          private int received;

          @Override
          protected void hookOnSubscribe(Subscription subscription) {
            request(2);
          }

          @Override
          protected void hookOnNext(Integer value) {
            System.out.println("   onNext " + value);
            if (++received % 2 == 0) {
              request(2);   // ready for the next batch
            }
          }

          @Override
          protected void hookOnComplete() {
            System.out.println("   onComplete");
          }
        });

    System.out.println("limitRate(3) - an unbounded subscriber, but requests upstream in batches:");
    Flux.range(1, 7)
        .doOnRequest(n -> System.out.println("   request(" + n + ")"))
        .limitRate(3)
        .subscribe(n -> System.out.println("   onNext " + n));
  }
}
 OUTPUT of the above code
a subscriber that takes 2 elements at a time:
   request(2)
   onNext 1
   onNext 2
   request(2)
   onNext 3
   onNext 4
   request(2)
   onNext 5
   onComplete
limitRate(3) - an unbounded subscriber, but requests upstream in batches:
   request(3)
   onNext 1
   onNext 2
   onNext 3
   request(3)
   onNext 4
   onNext 5
   onNext 6
   request(3)
   onNext 7
  • The first subscriber received exactly as many elements as it requested, two at a time. A fast database cursor or message queue behind such a pipeline reads only as fast as the consumer processes.
  • limitRate(3) turned the unbounded demand of the lambda subscriber into requests of 3 at a time: a new batch was requested only after the previous one had been delivered, so the source was never more than 3 elements ahead.
  • Sources that cannot slow down - mouse clicks, a ticker - need a strategy instead: onBackpressureBuffer(), onBackpressureDrop() or onBackpressureLatest().

Habits that keep reactive code correct

  • Return the publisher: a method that builds a Mono and does not return it (or subscribe to it) does nothing. Unused pipelines are a silent bug.
  • Never block on a reactive thread: no block(), Thread.sleep() or JDBC in a WebFlux handler. Wrap unavoidable blocking calls with subscribeOn(Schedulers.boundedElastic()).
  • No shared mutable state in operators: they may run on different threads. Pass data through the pipeline instead.
  • ThreadLocals do not follow the pipeline: use Reactor's Context, which Spring uses for security and tracing in WebFlux.
  • Read stack traces with care: they show Reactor's internals rather than your call chain; checkpoint("description") marks a spot in the pipeline that error reports then mention.

Summary

In this tutorial we met Project Reactor, the reactive library under Spring WebFlux. Reactive Streams publishers send onNext signals, then onComplete or onError, only after a subscriber has subscribed and requested elements. Mono carries 0 or 1 element and Flux 0 to N, and both are lazy, cold recipes that run once per subscriber. flatMap ran inner calls concurrently and interleaved their results while concatMap kept their order, zip combined two delayed values in the time of one, error operators replaced try-catch, publishOn and subscribeOn moved work between schedulers, and request(n) let a subscriber set the pace. The next tutorial uses all of this in a web server: REST APIs with Spring WebFluxClick to read tutorial: Building REST APIs with Spring WebFlux.