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 typesMono 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.
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 asjava.util.concurrent.Flow. It has four interfaces -
Publisher<T>: a source of data, with one method,subscribe(Subscriber).MonoandFluxare publishers.Subscriber<T>: receives the signalsonSubscribe,onNext(one per element), and finallyonCompleteoronError.Subscription: the link between the two. The subscriber callsrequest(n)on it to say how many more elements it can take - this is backpressure - orcancel().Processor<T, R>: both at once, a stage in the middle.
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.
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 orOptional: 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.
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));
}
}
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
lazydid not callload(); each subscription did. AMonoorFluxis 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 theMonois created, by plain Java rules.Mono.just(load("111"))would load at once, subscribed or not;fromSupplier(),fromCallable()anddefer()wait for a subscriber.- An empty publisher sends only
onComplete, and an error ends the stream withonError- errors are signals, not thrown exceptions.
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");
}
}
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
maptransforms each element synchronously, with a plain function.flatMaptransforms 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".flatMapsubscribed to the reviews of A and B at once. B's were faster, so they came first: the results interleaved, in order of arrival.concatMapwaited for A's reviews to complete before starting B's: the order was kept, at the cost of the waiting.zipwaited for both values, which were fetched at the same time: 200 ms in all, not 400.delayElementsanddelayElementrun on Reactor's timer threads, so the main method had to wait for the results withblockLast()orblock(). In application code,block()throws away everything reactive programming gains - and on Reactor's own threads it throws an exception.
Errors are signals
An exception thrown inside an operator, or an error from a publisher, travels down the pipeline as anonError 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));
}
}
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
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 likedelayElements, 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();
}
}
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
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, withrequest(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));
}
}
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()oronBackpressureLatest().
Habits that keep reactive code correct
- Return the publisher: a method that builds a
Monoand 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 withsubscribeOn(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 sendonNext 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.Reactive Spring Tutorials
Building REST APIs with Spring WebFluxClick to read tutorial: Building REST APIs with Spring WebFlux Calling HTTP APIs with Spring WebClientClick to read tutorial: Calling HTTP APIs with Spring WebClient Reactive Database Access with Spring Data R2DBCClick to read tutorial: Reactive Database Access with Spring Data R2DBC Server-Sent Events and WebSockets in Spring WebFluxClick to read tutorial: Server-Sent Events and WebSockets in Spring WebFlux Virtual Threads vs WebFlux: Which to Choose in 2026Click to read tutorial: Virtual Threads vs WebFlux: Which to Choose in 2026 Testing Reactive Code with StepVerifierClick to read tutorial: Testing Reactive Code with StepVerifier
Building REST APIs with Spring WebFluxClick to read tutorial: Building REST APIs with Spring WebFlux Calling HTTP APIs with Spring WebClientClick to read tutorial: Calling HTTP APIs with Spring WebClient Reactive Database Access with Spring Data R2DBCClick to read tutorial: Reactive Database Access with Spring Data R2DBC Server-Sent Events and WebSockets in Spring WebFluxClick to read tutorial: Server-Sent Events and WebSockets in Spring WebFlux Virtual Threads vs WebFlux: Which to Choose in 2026Click to read tutorial: Virtual Threads vs WebFlux: Which to Choose in 2026 Testing Reactive Code with StepVerifierClick to read tutorial: Testing Reactive Code with StepVerifier