Project Reactor

Last update: 2024-12-16 18:45:59 +0800 +0800

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:

java
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:

java
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:

java
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:

java
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:

java
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:

text
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:

java
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:

java
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:

java
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:

java
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:

java
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:

java
Mono<User> user = Mono.justOrEmpty(nullableUser);

Assembly, Subscription, and Signals #

Reactor code has three distinct phases.

Assembly #

At assembly time, operators create publisher objects:

java
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:

java
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:

text
source -> filter -> map -> subscriber

Each subscriber gets a separate execution of a cold pipeline. The same publisher object may therefore run its source more than once:

java
Mono<String> request = Mono.fromSupplier(this::loadValue);

request.subscribe(System.out::println); // calls loadValue
request.subscribe(System.out::println); // calls loadValue again

Creating Publishers #

Use the narrowest factory that describes the source.

Existing Values #

java
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:

java
Mono<Instant> currentTime = Mono.fromSupplier(Instant::now);

defer creates a publisher for each subscription:

java
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:

java
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:

java
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:

java
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:

java
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:

java
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:

java
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:

java
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:

java
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:

java
Flux<Event> events = Flux.merge(auditEvents, securityEvents);

concat #

concat completes one source before subscribing to the next:

java
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:

java
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:

text
subscriber A -> separate execution A
subscriber B -> separate execution B

A hot publisher exists independently of a subscriber. Consumers see values emitted while they are attached and may miss earlier values:

text
shared event source -> subscriber A
                    -> subscriber B

Operators can change sharing behavior:

java
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:

java
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:

text
onNext -> onNext -> onError

After onError, that subscription emits nothing else.

Replace an Error #

onErrorReturn emits a fallback value:

java
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:

java
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:

java
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:

java
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:

java
Mono<Response> response = client.call()
        .doFinally(signalType -> timer.stop());

For resources whose acquisition and cleanup are asynchronous, use usingWhen:

java
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:

java
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:

text
subscriber: request(32)
publisher:  emit at most 32 values
subscriber: process values, then request more

Reactor 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:

java
Flux<Integer> values = Flux.range(1, 3)
        .map(this::transform);

Reactor exposes execution policies through Schedulers:

SchedulerIntended 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:

java
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:

java
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:

java
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:

java
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:

java
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:

text
source
  -> operator reading Context
  -> contextWrite
  -> subscriber

Use 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:

  • Mono and Flux;
  • Sinks for programmatically emitting signals;
  • Signal and SignalType;
  • grouping, windowing, and parallel-publisher APIs;
  • hooks and operator infrastructure.

Sinks #

Sinks replaces older processor-based patterns for safely emitting signals from imperative code:

java
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:

java
Flux<String> values = service.findNames()
        .map(String::toUpperCase);

StepVerifier.create(values)
        .expectNext("ADA")
        .expectNext("GRACE")
        .verifyComplete();

Errors can be checked precisely:

java
StepVerifier.create(service.find("missing"))
        .expectError(NotFoundException.class)
        .verify();

Virtual time avoids waiting for real delays:

java
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:

java
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:

java
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:

text
Reactive Streams: interoperability protocol
        |
Project Reactor: Mono, Flux, operators, schedulers, Context
        |
Spring WebFlux: HTTP programming model, routing, codecs, controllers, clients

WebFlux 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:

java
@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:

java
@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:

java
@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>:

java
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:

java
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:

java
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:

java
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:

text
WebFlux controller
  -> blocking JPA repository
  -> JDBC driver
  -> thread waits

Reactive 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:

java
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 #

java
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 #

java
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 #

java
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 #

java
Mono<Mono<Profile>> nested = user.map(profileService::find);

Use flatMap to avoid a nested publisher:

java
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:

java
Mono<User> user = repository.findById(id)
        .switchIfEmpty(Mono.error(new NotFoundException(id)));

Use defer when constructing a fallback has side effects or material cost:

java
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 #

java
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 #

java
.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:

java
@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 #

  1. Return publishers from reusable code; subscribe at application boundaries.
  2. Use map for synchronous values and flatMap for publishers.
  3. Treat empty completion, failure, and cancellation as distinct outcomes.
  4. Bound flatMap concurrency, buffers, retries, and timeouts.
  5. Keep blocking work off event-loop and parallel workers.
  6. Prefer native nonblocking clients over wrapping blocking APIs.
  7. Use Context for cross-cutting subscription metadata, not domain inputs.
  8. Make side effects idempotent before retrying them.
  9. Test signals with StepVerifier instead of sleeps.
  10. 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)