Project Reactor is a Java library for composing
asynchronous and event-driven programs. Its core types, Mono<T> and
Flux<T>, describe computations that may produce values later rather than
returning those values immediately.
Reactor is the reactive foundation used by Spring WebFlux. It is also useful outside HTTP servers for asynchronous clients, event streams, messaging, concurrent orchestration, and any pipeline whose inputs may arrive over time.
The important word is describe. This code does not fetch a user when the line is evaluated:
Mono<User> user = repository.findById(userId);It creates a publisher that knows how to fetch the user when something subscribes. Operators build a processing graph around that publisher:
Mono<UserView> view = repository.findById(userId)
.filter(User::isActive)
.flatMap(profileService::attachProfile)
.map(UserView::from);The graph normally starts doing work only after subscription. Values, completion, and failures then travel through the graph as signals.
Reactor is not a way to make blocking code nonblocking by changing its return type. Its benefits come when the entire path uses nonblocking APIs or when blocking boundaries are isolated deliberately.
The Programming Model #
Traditional imperative code expresses control flow through statements:
User user = repository.findById(userId);
Profile profile = profileClient.fetch(user.profileId());
return UserView.from(user, profile);Each call returns before the next statement runs. If fetch waits for a
network response, the current thread usually waits with it.
Reactor expresses the same dependency as a pipeline:
Mono<UserView> result = repository.findById(userId)
.flatMap(user ->
profileClient.fetch(user.profileId())
.map(profile -> UserView.from(user, profile))
);flatMap says that the second asynchronous operation depends on the first
value. No thread has to remain blocked solely to remember what should happen
next. When the profile arrives, Reactor sends it to the next operator.
This model is:
- declarative: code describes transformations and dependencies;
- lazy by default: building a pipeline generally does not execute it;
- asynchronous-capable: signals may arrive on another thread at a later time;
- composable: a method can return a publisher for another method to extend;
- signal-based: values, errors, completion, cancellation, and demand are part of the protocol;
- backpressure-aware: a consumer can limit how much data it requests.
Reactive code is not inherently concurrent. A pipeline can execute synchronously on the subscribing thread. Asynchrony and thread switches occur only when a source or operator introduces them.
Reactive Streams #
Reactor implements the Reactive Streams specification. The specification defines four interfaces:
public interface Publisher<T> {
void subscribe(Subscriber<? super T> subscriber);
}
public interface Subscriber<T> {
void onSubscribe(Subscription subscription);
void onNext(T value);
void onError(Throwable error);
void onComplete();
}
public interface Subscription {
void request(long count);
void cancel();
}
public interface Processor<T, R>
extends Subscriber<T>, Publisher<R> {
}The protocol is roughly:
Subscriber -> subscribe -> Publisher
Subscriber <- onSubscribe(Subscription)
Subscriber -> request(n) -> Subscription
Subscriber <- onNext(value), at most n times
Subscriber <- onComplete() or onError(error)onComplete and onError are terminal signals. Exactly one of them may end a
successful subscription, and no more values follow it. Cancellation ends the
subscriber’s interest and should cause upstream work to stop where possible.
Application code rarely implements these interfaces directly. Mono, Flux,
and their operators enforce most of the protocol. The interfaces still explain
why demand, cancellation, and terminal errors behave as they do.
Mono and Flux
#
The main APIs are in reactor.core.publisher.
Mono<T>
#
A Mono<T> emits:
- one value followed by completion;
- completion without a value; or
- an error.
It represents cardinality of zero or one:
Mono<User> user = Mono.just(existingUser);
Mono<User> absent = Mono.empty();
Mono<User> failed = Mono.error(
new IllegalStateException("User service unavailable")
);Typical uses include one HTTP response, one database row, one command result, an optional lookup, or an asynchronous operation that only signals completion:
Mono<Void> saved = repository.save(user).then();Mono<Void> means that completion or failure matters but no value is emitted.
It does not mean that the operation is synchronous.
Flux<T>
#
A Flux<T> emits zero to many values, then completes or fails:
Flux<String> names = Flux.just("Ada", "Grace", "Linus");
Flux<Integer> range = Flux.range(1, 10);
Flux<Event> noEvents = Flux.empty();It can represent a finite collection, rows streamed from a database, messages from a broker, server-sent events, or an unbounded stream:
Flux<Long> ticks = Flux.interval(Duration.ofSeconds(1));An unbounded source does not complete by itself. Consumers must remain
subscribed, cancel it, or apply an operator such as take.
Cardinality Is Part of the Type #
Operators preserve or change cardinality:
Flux<Order> orders = orderRepository.findByCustomer(customerId);
Mono<Long> count = orders.count();
Mono<List<Order>> list = orders.collectList();
Flux<String> ids = orders.map(Order::id);
Mono<Order> first = orders.next();Choosing Mono or Flux communicates more than implementation detail. A
method returning Mono<Account> promises at most one account. Returning
Flux<Account> leaves open the possibility of many.
Neither type permits null as an emitted value. Use Mono.empty() for an
absent result and map nullable values at the boundary:
Mono<User> user = Mono.justOrEmpty(nullableUser);Assembly, Subscription, and Signals #
Reactor code has three distinct phases.
Assembly #
At assembly time, operators create publisher objects:
Flux<String> pipeline = Flux.just("a", "bb", "ccc")
.filter(value -> value.length() > 1)
.map(String::toUpperCase);No values normally pass through the pipeline yet.
Subscription #
A terminal consumer subscribes:
pipeline.subscribe(
value -> System.out.println("value: " + value),
error -> System.err.println("error: " + error.getMessage()),
() -> System.out.println("complete")
);This example is suitable for demonstrating the API, but application services should usually return the publisher rather than subscribe internally. The framework at the outer boundary, such as Spring WebFlux, owns subscription, cancellation, and response writing.
Calling subscribe() and discarding the returned handle creates detached work
that is difficult to cancel, observe, test, or include in the caller’s error
handling.
Execution #
After subscription, the source emits signals through the operator chain:
source -> filter -> map -> subscriberEach subscriber gets a separate execution of a cold pipeline. The same publisher object may therefore run its source more than once:
Mono<String> request = Mono.fromSupplier(this::loadValue);
request.subscribe(System.out::println); // calls loadValue
request.subscribe(System.out::println); // calls loadValue againCreating Publishers #
Use the narrowest factory that describes the source.
Existing Values #
Mono<Order> one = Mono.just(order);
Mono<Order> maybe = Mono.justOrEmpty(optionalOrder);
Flux<Order> many = Flux.fromIterable(orders);just captures its value at assembly time.
Deferred Work #
fromSupplier invokes a synchronous supplier for each subscription:
Mono<Instant> currentTime = Mono.fromSupplier(Instant::now);defer creates a publisher for each subscription:
Mono<User> currentUser = Mono.defer(() -> {
String userId = securityContext.currentUserId();
return repository.findById(userId);
});This distinction matters for mutable state, request context, time, and other values that must be read when execution starts rather than when the pipeline is assembled.
Callback APIs #
Mono.create and Flux.create adapt callback-style APIs:
Mono<Result> result = Mono.create(sink ->
client.execute(new Callback() {
@Override
public void onSuccess(Result value) {
sink.success(value);
}
@Override
public void onFailure(Throwable error) {
sink.error(error);
}
})
);The adapter must correctly handle cancellation, multiple callback invocations, and concurrent signals. Prefer an existing native Reactor integration when one is available.
Flux.generate is appropriate for synchronous, one-signal-at-a-time state
generation. Flux.create supports asynchronous and potentially multi-threaded
producers, but requires an explicit overflow strategy when the producer can
outpace demand.
Transforming Values #
Reactor’s operators form the vocabulary of the programming model.
map
#
Use map for a synchronous one-to-one transformation:
Flux<String> normalized = names
.map(String::trim)
.map(String::toLowerCase);The mapping function returns an ordinary value, not a publisher.
filter
#
filter retains values that match a predicate:
Flux<Order> expensiveOrders = orders
.filter(order -> order.total().compareTo(minimum) >= 0);Filtering the only value from a Mono produces an empty Mono.
flatMap
#
Use flatMap when each input starts another asynchronous publisher:
Flux<Invoice> invoices = customerIds
.flatMap(invoiceService::findCurrentInvoice);For Flux, inner publishers may run concurrently, and their values may be
interleaved. The overloads accepting concurrency and prefetch limit active
work and upstream demand:
Flux<Invoice> invoices = customerIds.flatMap(
invoiceService::findCurrentInvoice,
16
);Unlimited remote calls are rarely a safe production default.
concatMap
#
concatMap subscribes to one inner publisher at a time and preserves source
order:
Flux<ImportResult> results = files.concatMap(importer::importFile);It trades concurrency for ordering and serialized side effects.
flatMapSequential
#
flatMapSequential starts inner publishers concurrently but emits their
results in source order. Results from later publishers may be buffered while
an earlier publisher is still running.
switchMap
#
switchMap cancels the previous inner publisher when a new input arrives:
Flux<SearchResult> results = queryChanges
.switchMap(searchService::search);This is useful for live search, where results for an obsolete query should no longer be delivered.
Combining Publishers #
The correct operator depends on the relationship between inputs.
zip
#
zip waits for one value from each source and combines them:
Mono<AccountPage> page = Mono.zip(
accountService.find(accountId),
preferenceService.find(accountId),
AccountPage::new
);If a zipped Mono completes empty, the combination cannot be produced.
merge
#
merge subscribes to sources eagerly and interleaves values as they arrive:
Flux<Event> events = Flux.merge(auditEvents, securityEvents);concat
#
concat completes one source before subscribing to the next:
Flux<Event> events = Flux.concat(historicalEvents, liveEvents);This pattern can avoid missing the intended ordering, but a production history-to-live transition may still need source-specific coordination to avoid losing events between subscriptions.
combineLatest
#
After every source has emitted at least once, combineLatest recomputes a
result whenever any source changes. It is useful for continuously changing
state rather than one-for-one pairing.
Completion Operators #
then ignores values and continues after successful completion:
Mono<Receipt> result = validator.validate(command)
.then(repository.save(command))
.then(paymentService.charge(command));thenReturn(value) emits a fixed value after completion. thenMany(publisher)
continues with a multi-value publisher.
Cold and Hot Publishers #
A cold publisher creates or replays its sequence independently for each
subscriber. HTTP client calls, database queries, and Flux.range are commonly
cold:
subscriber A -> separate execution A
subscriber B -> separate execution BA hot publisher exists independently of a subscriber. Consumers see values emitted while they are attached and may miss earlier values:
shared event source -> subscriber A
-> subscriber BOperators can change sharing behavior:
Flux<Quote> shared = quoteClient.stream()
.publish()
.refCount(1);The first subscriber connects to the source, later subscribers share that subscription, and the source disconnects when the last subscriber leaves.
share() is a convenient form of reference-counted sharing. cache() stores
and replays signals to later subscribers:
Mono<Configuration> cached = configurationClient.load()
.cache(Duration.ofMinutes(5));Caching also affects errors and empty completion according to the selected overload. It must have an intentional lifetime and invalidation policy.
Error Handling #
An error is a terminal signal. It does not travel as an ordinary value that
the next map can inspect:
onNext -> onNext -> onErrorAfter onError, that subscription emits nothing else.
Replace an Error #
onErrorReturn emits a fallback value:
Mono<ExchangeRate> rate = rateClient.latest(pair)
.onErrorReturn(ExchangeRate.unavailable(pair));Use it only when the fallback is semantically valid. Turning infrastructure failure into plausible data can hide an outage.
Select Another Publisher #
onErrorResume chooses a fallback publisher:
Mono<Product> product = primary.find(productId)
.onErrorResume(NotFoundException.class,
error -> archive.find(productId));The original upstream sequence has terminated. Recovery continues with a new publisher; it does not resume the failed source at the next element.
Translate an Error #
onErrorMap preserves failure while changing its type:
Mono<Product> product = client.fetch(productId)
.onErrorMap(
ClientProtocolException.class,
error -> new CatalogUnavailableException(productId, error)
);Observe Without Recovering #
doOnError, doOnNext, doOnSubscribe, and related doOn... operators add
side effects without replacing the signal:
Mono<Product> product = client.fetch(productId)
.doOnError(error ->
LOGGER.warn("Product lookup failed: {}", productId, error));Do not add the same error log at every layer. Log where sufficient context exists and where duplicate reporting will not be produced.
Cleanup #
doFinally runs for completion, error, or cancellation:
Mono<Response> response = client.call()
.doFinally(signalType -> timer.stop());For resources whose acquisition and cleanup are asynchronous, use
usingWhen:
Flux<Row> rows = Flux.usingWhen(
connectionFactory.create(),
connection -> query(connection),
Connection::close
);Cancellation must be treated as a normal termination path, especially for HTTP disconnects and timed-out requests.
Retry #
Retry resubscribes to the source:
Mono<Response> response = client.call()
.retryWhen(
Retry.backoff(3, Duration.ofMillis(200))
.filter(this::isTransient)
);Resubscription repeats upstream work. A retry around a payment, message publication, or database mutation can repeat a side effect. Retry only transient failures, bound attempts and delay, and make effectful operations idempotent where possible.
Backpressure #
Reactive Streams lets downstream request a bounded number of values. This prevents a compliant upstream publisher from sending unlimited data to a consumer that cannot keep up:
subscriber: request(32)
publisher: emit at most 32 values
subscriber: process values, then request moreReactor operators usually manage demand automatically. Prefetching operators may request values in batches for throughput.
Backpressure is not unlimited buffering and cannot slow every external system. An HTTP endpoint may be able to stop reading a socket, while a firehose-style callback API may continue producing events. At such boundaries the application must choose a policy:
- buffer within a strict bound;
- discard old or new values;
- keep only the latest value;
- signal an overflow error;
- apply admission control before accepting work.
Operators such as onBackpressureBuffer, onBackpressureDrop, and
onBackpressureLatest make that policy explicit. An unbounded buffer only
moves the failure from overload to memory exhaustion.
Backpressure limits items in a stream. It does not by itself limit every
resource. Use flatMap concurrency, connection pools, queues, timeouts, and
rate limits to bound downstream calls and work in flight.
Threads and Schedulers #
Reactor is concurrency-agnostic. Without a scheduler boundary, operators usually execute on the thread that emits the signal:
Flux<Integer> values = Flux.range(1, 3)
.map(this::transform);Reactor exposes execution policies through Schedulers:
| Scheduler | Intended Use |
|---|---|
Schedulers.immediate() | Continue on the current thread |
Schedulers.single() | Shared single worker for serialized work |
Schedulers.parallel() | Fixed pool for short CPU-bound work |
Schedulers.boundedElastic() | Bounded accommodation for blocking I/O |
Schedulers.fromExecutorService(...) | Integrate an owned executor |
Schedulers are shared resources unless an API explicitly creates a new one. Do not create a scheduler per request.
subscribeOn
#
subscribeOn affects where subscription and source execution begin:
Mono<Report> report = Mono.fromCallable(() -> blockingReportClient.load(id))
.subscribeOn(Schedulers.boundedElastic());Its effect is not limited to operators textually below it. The placement is usually kept close to the blocking source for readability.
publishOn
#
publishOn changes the execution context for downstream operators from that
point:
Flux<Result> results = source
.publishOn(Schedulers.parallel())
.map(this::cpuIntensiveTransform)
.publishOn(responseScheduler)
.map(this::format);Each boundary introduces queueing and scheduling overhead. Thread switches are not automatically an optimization.
Blocking Boundaries #
When a blocking API cannot yet be replaced, isolate it:
Mono<Customer> customer = Mono.fromCallable(
() -> jdbcRepository.find(customerId)
)
.subscribeOn(Schedulers.boundedElastic());boundedElastic prevents that call from blocking an event-loop worker, but it
does not turn the database driver into nonblocking I/O. The operation still
occupies a thread, the scheduler has finite capacity, and overload still needs
limits.
Do not put CPU-intensive work on boundedElastic; use a bounded CPU scheduler.
Do not block Netty event-loop or Reactor parallel workers.
block()
#
Mono.block() and Flux.blockFirst() bridge reactive code back to an
imperative caller:
Customer customer = customerClient.find(customerId)
.block(Duration.ofSeconds(2));They are acceptable at a controlled outer boundary such as a command-line program or legacy adapter. They should not appear inside a WebFlux request pipeline. Blocking there wastes event-loop capacity and may fail on threads that reject blocking operations.
Each block() creates a new subscription. Calling it repeatedly can repeat a
cold HTTP request or database query.
Reactor Context #
Thread-local state does not reliably follow a reactive pipeline because
execution may move between threads. Reactor provides Context for metadata
associated with a subscription:
Mono<String> greeting = Mono.deferContextual(contextView -> {
String requestId = contextView.get("requestId");
return Mono.just("request=" + requestId);
});
Mono<String> result = greeting.contextWrite(
context -> context.put("requestId", "req-123")
);Context is:
- scoped to a subscriber rather than a thread;
- immutable, with write operations returning a new context;
- intended for orthogonal metadata such as tracing, security, or request correlation;
- propagated from downstream toward upstream when subscription occurs.
That last point can be surprising. contextWrite makes data visible to
operators above it in the chain, not arbitrary code below it:
source
-> operator reading Context
-> contextWrite
-> subscriberUse ordinary arguments and domain objects for business data. Hiding required
inputs in Context makes APIs difficult to understand and test.
Spring Security and observability integrations may use Reactor context internally. Application code should use their supported APIs rather than depending on private context keys.
Important Reactor APIs #
Reactor is broader than Mono and Flux.
reactor.core.publisher
#
This package contains:
MonoandFlux;Sinksfor programmatically emitting signals;SignalandSignalType;- grouping, windowing, and parallel-publisher APIs;
- hooks and operator infrastructure.
Sinks
#
Sinks replaces older processor-based patterns for safely emitting signals
from imperative code:
Sinks.Many<Event> sink = Sinks.many()
.multicast()
.onBackpressureBuffer();
Flux<Event> events = sink.asFlux();
Sinks.EmitResult result = sink.tryEmitNext(event);
if (result.isFailure()) {
handleEmissionFailure(result, event);
}Sink configuration defines replay, subscriber count, and buffering behavior.
Emission can fail because there is no subscriber, the sink terminated, demand
is insufficient, or several threads emit concurrently. Do not ignore
EmitResult.
reactor.core.scheduler
#
This package contains Scheduler, Schedulers, and worker abstractions.
Schedulers express where work runs; they are not just interchangeable thread
pools.
reactor.util.context
#
Context and ContextView expose subscriber-scoped metadata.
reactor.util.retry
#
Retry and RetryBackoffSpec define bounded retry policies, filters, backoff,
jitter, and exhausted-retry behavior.
reactor.test
#
The separate reactor-test artifact provides StepVerifier,
TestPublisher, and tools for virtual time.
Testing with StepVerifier
#
Calling subscribe() and sleeping produces slow and unreliable tests.
StepVerifier describes expected signals:
Flux<String> values = service.findNames()
.map(String::toUpperCase);
StepVerifier.create(values)
.expectNext("ADA")
.expectNext("GRACE")
.verifyComplete();Errors can be checked precisely:
StepVerifier.create(service.find("missing"))
.expectError(NotFoundException.class)
.verify();Virtual time avoids waiting for real delays:
StepVerifier.withVirtualTime(
() -> Flux.interval(Duration.ofHours(1)).take(2)
)
.expectSubscription()
.expectNoEvent(Duration.ofHours(1))
.expectNext(0L)
.thenAwait(Duration.ofHours(1))
.expectNext(1L)
.verifyComplete();The publisher must be created inside the supplier so it uses the virtual scheduler installed by the test.
Use TestPublisher to simulate malformed, delayed, or manually controlled
sources, and PublisherProbe to verify that a fallback path was subscribed or
cancelled.
Debugging Pipelines #
Reactive stack traces can be difficult because assembly and execution may happen at different places and times.
Name important sequences and add targeted checkpoints:
Mono<Invoice> invoice = repository.find(invoiceId)
.name("invoice-lookup")
.checkpoint("load invoice for payment");A checkpoint adds assembly information to later errors. A forced checkpoint captures more detail but adds stack-trace overhead.
Hooks.onOperatorDebug() enables broad assembly tracing and is useful during
diagnosis, but it has a runtime cost. Prefer framework-supported development
diagnostics or targeted checkpoints in production code.
The log() operator prints reactive signals:
Flux<Order> orders = repository.findAll()
.log("orders");It can be noisy and may expose values. Use it temporarily or configure the logger deliberately rather than leaving high-volume signal logging enabled.
Spring WebFlux #
Spring WebFlux is Spring Framework’s reactive web stack. It supports nonblocking request handling, Reactive Streams backpressure, and servers including Reactor Netty and Servlet containers.
The relationship is:
Reactive Streams: interoperability protocol
|
Project Reactor: Mono, Flux, operators, schedulers, Context
|
Spring WebFlux: HTTP programming model, routing, codecs, controllers, clientsWebFlux uses Reactor as its primary implementation. Controller methods may use
the generic Publisher interface, but Mono and Flux provide the operator
API used in normal application code.
Reactor does not know about HTTP routes, request bodies, status codes, or content negotiation. WebFlux adds those web concerns and subscribes to the publishers returned by application code.
Annotated Controllers #
WebFlux supports familiar Spring annotations:
@RestController
@RequestMapping("/users")
public final class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/{id}")
public Mono<ResponseEntity<UserView>> find(
@PathVariable String id
) {
return userService.find(id)
.map(UserView::from)
.map(ResponseEntity::ok)
.defaultIfEmpty(ResponseEntity.notFound().build());
}
@GetMapping
public Flux<UserView> findAll() {
return userService.findAll()
.map(UserView::from);
}
}The controller does not call subscribe(). WebFlux subscribes and translates:
- emitted values into an HTTP response body;
- empty completion into a response with no body;
- errors into configured error handling;
- request cancellation or client disconnect into upstream cancellation.
For a normal JSON array, codecs may need to collect or buffer enough data to write valid JSON. Streaming media types such as newline-delimited JSON and server-sent events let values be flushed incrementally.
Request Bodies #
A request body can also be reactive:
@PostMapping(
path = "/events",
consumes = MediaType.APPLICATION_NDJSON_VALUE
)
public Mono<Void> ingest(@RequestBody Flux<EventInput> inputs) {
return inputs
.map(Event::from)
.transform(eventService::saveAll)
.then();
}Whether processing is truly streaming depends on the media type, codec,
validation, and downstream service. A Flux return type alone does not
guarantee constant memory use.
Functional Endpoints #
WebFlux also provides a functional routing API:
@Bean
RouterFunction<ServerResponse> userRoutes(UserHandler handler) {
return RouterFunctions.route()
.GET("/users/{id}", handler::find)
.GET("/users", handler::findAll)
.build();
}Handlers receive ServerRequest and return Mono<ServerResponse>:
public Mono<ServerResponse> find(ServerRequest request) {
String id = request.pathVariable("id");
return userService.find(id)
.flatMap(user -> ServerResponse.ok().bodyValue(user))
.switchIfEmpty(ServerResponse.notFound().build());
}Annotated and functional endpoints use the same WebFlux runtime. The choice is about API style and route composition, not different performance behavior.
WebClient
#
WebClient is Spring’s reactive HTTP client:
public Mono<Product> findProduct(String productId) {
return webClient.get()
.uri("/products/{id}", productId)
.retrieve()
.bodyToMono(Product.class)
.timeout(Duration.ofSeconds(2));
}Streaming bodies use bodyToFlux:
public Flux<Event> streamEvents() {
return webClient.get()
.uri("/events")
.accept(MediaType.TEXT_EVENT_STREAM)
.retrieve()
.bodyToFlux(Event.class);
}retrieve() provides convenient status-to-error handling. Use
exchangeToMono or exchangeToFlux when response status, headers, and body
require coordinated handling:
Mono<Product> product = webClient.get()
.uri("/products/{id}", productId)
.exchangeToMono(response -> {
if (response.statusCode().is2xxSuccessful()) {
return response.bodyToMono(Product.class);
}
if (response.statusCode().value() == 404) {
return Mono.empty();
}
return response.createError();
});The response body must be consumed or released in low-level exchange handling so pooled network buffers are not leaked.
WebClient can also be used by a Spring MVC application. Using the client does
not require converting the entire server to WebFlux; an MVC boundary may block
on the result in a controlled way or return an asynchronous type supported by
that stack.
Data Access Must Match the Model #
A nonblocking HTTP server does not make a blocking persistence layer nonblocking:
WebFlux controller
-> blocking JPA repository
-> JDBC driver
-> thread waitsReactive relational access commonly uses R2DBC. Reactive MongoDB, Redis, Cassandra, and messaging integrations provide their own publishers. Their actual behavior still depends on the driver.
Wrapping a JPA call in boundedElastic protects the event loop:
Mono<User> user = Mono.fromCallable(
() -> jpaRepository.findById(id).orElse(null)
)
.flatMap(Mono::justOrEmpty)
.subscribeOn(Schedulers.boundedElastic());This is an interoperability technique, not the same architecture as a nonblocking driver. It keeps one thread occupied per blocking call and can become the bottleneck under load.
WebFlux Backpressure Has Boundaries #
Within a Reactive Streams chain, WebFlux and Reactor propagate demand. At the network boundary, behavior also depends on the protocol and transport:
- a server can pause socket reads when downstream is slow;
- response writes may wait for the network to accept more data;
- an upstream broker or callback API may not support matching demand;
- an HTTP client cannot force a remote server to implement application-level backpressure;
- request and response codecs may buffer complete objects or collections.
Backpressure is useful flow control, not an end-to-end guarantee across every system. Configure maximum in-memory codec sizes, request limits, queue bounds, timeouts, and concurrency explicitly.
WebFlux Versus Spring MVC #
Spring MVC and Spring WebFlux are two web stacks with overlapping programming features but different execution assumptions.
Choose WebFlux when:
- most I/O dependencies have nonblocking drivers;
- the application maintains many concurrent slow connections;
- endpoints stream data or server-sent events;
- the system composes several asynchronous services per request;
- backpressure and cancellation are useful throughout the path.
Spring MVC is often the simpler choice when:
- the application is primarily CRUD over blocking JDBC or JPA;
- libraries and team conventions are imperative;
- request concurrency is moderate;
- the work is dominated by blocking APIs anyway.
WebFlux can handle high concurrency with a small event-loop pool because threads are not parked for compatible I/O. It does not make CPU work faster, reduce the latency of a remote service, or eliminate capacity planning. Reactive pipelines also have a learning and debugging cost.
Virtual threads change the trade-off for thread-per-request applications by making blocking code more scalable. They do not replace streaming, backpressure, cancellation propagation, or reactive APIs, but they make Spring MVC a stronger option when the application’s dependencies are naturally blocking.
Do not select WebFlux merely because it is newer or because a controller can
return Mono. Select one coherent execution model based on the whole call
path.
Common Mistakes #
Subscribing in a Service #
public void update(User user) {
repository.save(user).subscribe();
}This detaches errors and cancellation from the caller. Return Mono<Void> or
Mono<User> and let the boundary subscribe.
Blocking Inside an Operator #
return users.map(user -> blockingClient.load(user.id()));The mapping function runs on the signal thread and may block an event loop.
Use a nonblocking client or isolate the blocking source on boundedElastic.
Calling block() in WebFlux
#
User user = userService.find(id).block();
return Mono.just(user);This defeats the execution model and can deadlock or throw on nonblocking
workers. Compose with map and flatMap.
Using map for an Asynchronous Function
#
Mono<Mono<Profile>> nested = user.map(profileService::find);Use flatMap to avoid a nested publisher:
Mono<Profile> profile = user.flatMap(profileService::find);Ignoring Empty Publishers #
An empty Mono does not invoke map or flatMap. Use switchIfEmpty,
defaultIfEmpty, or preserve absence intentionally:
Mono<User> user = repository.findById(id)
.switchIfEmpty(Mono.error(new NotFoundException(id)));Use defer when constructing a fallback has side effects or material cost:
Mono<User> user = primary.find(id)
.switchIfEmpty(Mono.defer(() -> archive.find(id)));Assuming flatMap Preserves Order
#
Concurrent inner publishers complete in arrival order. Use concatMap or
flatMapSequential when ordering is part of the contract.
Unbounded Concurrency #
ids.flatMap(remoteClient::fetch);For a large or unbounded source, limit concurrency and ensure the HTTP client’s connection pool has compatible bounds.
Treating Context as a Map of Method Arguments
#
Required domain inputs belong in parameters and values. Context is for cross-cutting subscription metadata.
Hiding Errors with Broad Fallbacks #
.onErrorResume(error -> Mono.empty())This turns every failure into absence. Match only errors whose fallback meaning is defined, and let unexpected failures remain failures.
A Complete Service Example #
The following service validates a request, performs two independent calls, limits execution time, translates expected failures, and leaves subscription to WebFlux:
@Service
public final class CheckoutService {
private final InventoryClient inventoryClient;
private final PricingClient pricingClient;
private final OrderRepository orderRepository;
public CheckoutService(
InventoryClient inventoryClient,
PricingClient pricingClient,
OrderRepository orderRepository
) {
this.inventoryClient = inventoryClient;
this.pricingClient = pricingClient;
this.orderRepository = orderRepository;
}
public Mono<Order> checkout(CheckoutCommand command) {
Mono<InventoryReservation> reservation =
inventoryClient.reserve(command.items());
Mono<Price> price =
pricingClient.calculate(command.items(), command.coupon());
return Mono.zip(reservation, price)
.map(tuple -> Order.create(
command.customerId(),
tuple.getT1(),
tuple.getT2()
))
.flatMap(orderRepository::save)
.timeout(Duration.ofSeconds(3))
.onErrorMap(
TimeoutException.class,
error -> new CheckoutUnavailableException(error)
)
.checkpoint("checkout order");
}
}Mono.zip subscribes to the inventory and pricing publishers so they can
progress concurrently. If either fails, the combined publisher fails and the
other subscription is cancelled where possible. Saving starts only after both
values arrive.
Real checkout logic must also define compensation and idempotency. Cancellation does not guarantee that a remote reservation or payment was undone, because the external system may already have committed it.
Practical Design Rules #
- Return publishers from reusable code; subscribe at application boundaries.
- Use
mapfor synchronous values andflatMapfor publishers. - Treat empty completion, failure, and cancellation as distinct outcomes.
- Bound
flatMapconcurrency, buffers, retries, and timeouts. - Keep blocking work off event-loop and parallel workers.
- Prefer native nonblocking clients over wrapping blocking APIs.
- Use
Contextfor cross-cutting subscription metadata, not domain inputs. - Make side effects idempotent before retrying them.
- Test signals with
StepVerifierinstead of sleeps. - Choose WebFlux only when the broader application can benefit from its nonblocking model.
Project Reactor’s central idea is not “everything is a stream.” It is that an asynchronous computation can be represented as a composable publisher with a defined protocol for values, completion, errors, demand, and cancellation. Spring WebFlux applies that model to HTTP, but Reactor remains the lower-level programming model that makes the composition possible.
Comments
Read or comment on GitHub (sign-in required to comment)