Spring WebFlux: Reactive Programming with Spring Boot 3 - Real-World Patterns
Master Spring WebFlux in Spring Boot 3.
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
- ✓Java 17 or later installed
- ✓Spring Boot 3.2+ project with spring-boot-starter-webflux dependency
- ✓Basic understanding of reactive streams (Publisher, Subscriber, Subscription)
• Spring WebFlux is a reactive-stack web framework for building non-blocking, asynchronous applications on Spring Boot 3, using Project Reactor (Mono/Flux) and Netty by default.
• It handles high concurrency with fewer threads compared to traditional Servlet-based Spring MVC, ideal for I/O-bound workloads like payment-processing or real-time analytics.
• Key differences: WebFlux uses reactive types (Mono
Think of Spring WebFlux as a high-end coffee shop barista who can handle 100 orders simultaneously without ever waiting for a coffee machine to finish. In Spring MVC, the barista would block while a coffee brews, unable to take new orders. In WebFlux, the barista writes down the order, starts the machine, and immediately moves to the next customer. When the coffee is ready, a notification rings, and the barista finishes the order. This is non-blocking, event-driven concurrency—perfect for handling thousands of API requests without spinning up a thread per request.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
For over a decade, Spring MVC with Tomcat was the gold standard for building web applications in Java. But as we moved into the era of microservices, IoT, and real-time analytics, the synchronous, thread-per-request model started showing cracks. Each thread consumes roughly 1MB of stack memory, and under high concurrency—say 10,000 concurrent WebSocket connections for a live trading dashboard—you either run out of memory or hit thread-pool limits. Spring WebFlux, introduced in Spring 5 and matured in Spring Boot 3, solves this by embracing reactive programming with Project Reactor. Instead of blocking on I/O, WebFlux uses event loops and backpressure-aware streams (Mono and Flux) to handle thousands of concurrent requests with a handful of threads. This makes it a natural fit for I/O-bound services: API gateways, streaming data pipelines, and payment-processing systems where latency is critical. However, WebFlux is not a drop-in replacement for Spring MVC. It requires a paradigm shift in how you think about data flow, error handling, and even database access. This article walks you through building a reactive REST API with Spring Boot 3, covering real-world patterns for error handling, backpressure, and testing. We'll also dissect a production incident where a naive blocking call brought down a reactive payment service. By the end, you'll understand not just how to use WebFlux, but when and why.
Setting Up a Reactive REST Controller with Mono and Flux
Let's start with a concrete example: a reactive REST API for a payment-processing service that returns a list of transactions and allows creating new ones. In Spring Boot 3, you create a reactive controller using @RestController and return Mono<T> for single items or Flux<T> for streams. The key difference from Spring MVC is that you never return a plain object or ResponseEntity directly—you return a reactive type that the framework subscribes to. Here's a simple controller that fetches transactions from a reactive MongoDB repository. Note that we use Flux<Transaction> for the collection endpoint and Mono<Transaction> for the single-item endpoint. The framework handles the subscription automatically. The real power comes when you combine multiple reactive sources: for example, fetching user details and transaction history concurrently using Mono.zip().
What the Official Docs Won't Tell You
The official Spring WebFlux documentation is excellent for the happy path, but it glosses over several production realities. First, error handling in reactive streams is fundamentally different from try-catch. You must use onErrorResume(), onErrorReturn(), or doOnError() at the operator level. A single unhandled error in a Flux chain will cancel the entire stream silently—no stack trace, no log, just a cancelled subscription. Second, the documentation doesn't emphasize enough that your entire data access layer must be reactive. If you use spring-boot-starter-data-jpa with WebFlux, you'll block on every database call because JPA is inherently blocking. You need spring-boot-starter-data-r2dbc or reactive MongoDB/Cassandra drivers. Third, backpressure is not automatic—you must configure it explicitly for high-volume streams. For example, when consuming from a Kafka topic via Reactor Kafka, you need to set the prefetch and maxConcurrent values to avoid overwhelming downstream services. Finally, testing reactive code requires StepVerifier from reactor-test, not JUnit's standard assertions. The official docs show basic examples, but in production you'll need virtual time testing with StepVerifier.withVirtualTime() to simulate slow publishers.
Building a Reactive Service Layer with Backpressure
A reactive service layer is where the real magic happens. Let's build a service that processes a stream of transaction events from a message broker (simulated here with a Flux from a list) and applies backpressure. Backpressure is the ability of the consumer to signal to the producer how much data it can handle. In Project Reactor, you control backpressure using operators like limitRate(), buffer(), or by implementing a custom Subscriber. For a payment-processing service, you might want to process transactions in batches of 10 to avoid overwhelming the database. Here's an example that reads a stream of transaction requests, validates them, and saves them in batches. Note the use of .buffer(10) to group items and .flatMapSequential() to maintain order while allowing concurrency. The key insight: without backpressure, a fast producer can flood a slow consumer, causing out-of-memory errors or database connection pool exhaustion.
buffer() to control the flow of data through your reactive pipeline. flatMap with a concurrency parameter prevents resource exhaustion.Reactive Data Access with R2DBC and MongoDB
WebFlux is only as reactive as your data layer. Spring Boot 3 provides reactive support for MongoDB (spring-boot-starter-data-mongodb-reactive) and relational databases via R2DBC (spring-boot-starter-data-r2dbc). R2DBC is the reactive alternative to JDBC, supporting PostgreSQL, MySQL, H2, and others. Here's a reactive repository for a PostgreSQL-backed transaction table using R2DBC. Notice that the repository interface extends ReactiveCrudRepository, and methods return Mono or Flux. The key difference from JPA: you cannot use lazy loading or entity graphs—everything is fetched eagerly via reactive streams. Also, transactions must be managed declaratively with @Transactional on reactive methods, but beware: @Transactional in R2DBC works on a reactive transaction manager, not the traditional PlatformTransactionManager. For MongoDB, the reactive driver is even more seamless, supporting reactive repositories out of the box. In production, we prefer MongoDB for purely reactive stacks because it avoids the impedance mismatch of mapping relational tables to reactive streams.
Testing Reactive Endpoints with StepVerifier and WebTestClient
Testing reactive code requires a different mindset. Standard JUnit assertions won't work because Mono and Flux are asynchronous—you need to subscribe to them and assert on the emitted items. Spring provides two key tools: StepVerifier for unit testing reactive streams, and WebTestClient for integration testing reactive controllers. StepVerifier allows you to create a script of expectations: expectNext(), expectComplete(), verifyError(). For time-dependent operators, use StepVerifier.withVirtualTime() to avoid waiting for real time. WebTestClient is a reactive alternative to MockMvc that binds to a WebFlux application context and sends/receives reactive types. Here's a test for the TransactionController we built earlier. Note that we use .exchange() to trigger the request and .expectBody() to assert on the response. For streaming endpoints, use .expectBodyList() or .returnResult() to consume the Flux.
Combining Multiple Reactive Sources with Zip and Merge
In real-world applications, you often need to combine data from multiple reactive sources. For example, in a payment-processing system, you might need to fetch the user's account balance, the transaction history, and the current exchange rate concurrently, then combine them into a single response. Project Reactor provides several operators for this: Mono.zip() for combining up to 8 Monos into a Tuple, Flux.merge() for interleaving multiple Flux streams, and Flux.concat() for sequential concatenation. Here's an example that fetches user details and recent transactions concurrently using Mono.zip(). The .zip() operator subscribes to all Monos immediately and waits for all to complete before emitting the combined result. This is the reactive equivalent of CompletableFuture.allOf(). For streaming scenarios, Flux.merge() allows you to process events from multiple sources as they arrive, which is ideal for real-time dashboards that aggregate data from Kafka topics.
Mono.zip() with up to 6 concurrent calls (user profile, positions, market data, risk limits, margin, and news). This reduced our API response time from 400ms to 80ms by parallelizing the requests.Mono.zip() to combine multiple independent reactive calls into a single result. Use Flux.merge() to interleave multiple streams for real-time processing.Handling Timeouts and Retries in Reactive Pipelines
In production, networks fail, databases become slow, and upstream services timeout. Reactive streams provide first-class operators for resilience: timeout(), retry(), and retryWhen(). The timeout() operator raises a TimeoutException if the source doesn't emit within a given duration. retry() resubscribes to the source on error (with a limit to avoid infinite loops). For more sophisticated retry strategies (exponential backoff, jitter), use retryWhen() with a Retry spec. Here's an example that sets a 5-second timeout on a database call and retries up to 3 times with exponential backoff. Note that retry() resubscribes to the original source, so if the source is a cold publisher (like a database query), it will re-execute. For hot publishers (like a WebSocket stream), retry() may not make sense because you'll miss events. Always consider idempotency: if the operation is not idempotent (e.g., inserting a payment), retrying may cause duplicate records. In that case, use a unique request ID to deduplicate on the server side.
timeout() to set deadlines on reactive operations, and retryWhen() with exponential backoff for transient failures. Always consider idempotency when retrying.Debugging Reactive Streams with Hooks and Logging
Debugging reactive streams is notoriously difficult because stack traces are often unhelpful—errors propagate through the operator chain, and the stack trace shows the assembly point, not the execution point. Project Reactor provides several tools to make debugging easier. First, use Hooks.onOperatorDebug() to enable operator-level debugging, which captures the assembly stack trace for each operator. This adds overhead, so enable it only in development or via a feature flag. Second, use .log() operator to log signals (onNext, onError, onComplete) for a specific part of the pipeline. Third, use .checkpoint() to mark a point in the stream with a description; when an error occurs downstream, the checkpoint appears in the stack trace. Here's an example that uses all three techniques to debug a pipeline that processes transactions. In production, we use a custom logging subscriber that logs at WARN level for errors and DEBUG for normal signals, and we always include a unique request ID in the log context to correlate logs across services.
Hooks.onOperatorDebug() for development, .checkpoint() for marking pipeline stages, and .log() sparingly for targeted debugging. Reactive debugging is about understanding the operator chain, not the thread stack.The Blocking JDBC Call That Froze Our Reactive Payment Gateway
Mono.fromCallable() would magically make it non-blocking.Mono.fromCallable() runs the blocking code on the calling thread (the Netty event loop), which blocks the entire event loop, preventing other requests from being processed.Schedulers.boundedElastic() for any unavoidable blocking operations.- Never assume a reactive wrapper makes blocking code non-blocking—it still runs on the event loop by default.
- Use
Schedulers.boundedElastic()to offload blocking I/O to a separate thread pool. - Audit your entire data access layer: if you use JDBC or JPA with WebFlux, you will have a bad time.
Hooks.onOperatorDebug() temporarily to capture assembly stack traces. Look for unhandled errors that cause the stream to cancel—check for missing onErrorResume() or onErrorContinue().Flux.buffer() without a max size, or flatMap() without a concurrency limit. Use limitRate() and buffer(1000) to cap memory usage. Monitor backpressure signals using .log() on the subscriber side.jstack <pid> | grep -A 20 'reactor-http-nio'Check for calls to .block(), Thread.sleep(), or JDBC in the stack trace.Schedulers.boundedElastic()).| File | Command / Code | Purpose |
|---|---|---|
| TransactionController.java | @RestController | Setting Up a Reactive REST Controller with Mono and Flux |
| ErrorHandlingExample.java | Mono | What the Official Docs Won't Tell You |
| TransactionService.java | @Service | Building a Reactive Service Layer with Backpressure |
| ReactiveTransactionRepository.java | public interface ReactiveTransactionRepository extends ReactiveCrudRepository | Reactive Data Access with R2DBC and MongoDB |
| TransactionControllerTest.java | @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) | Testing Reactive Endpoints with StepVerifier and WebTestClie |
| PaymentService.java | @Service | Combining Multiple Reactive Sources with Zip and Merge |
| ResilientPaymentService.java | @Service | Handling Timeouts and Retries in Reactive Pipelines |
| DebuggingExample.java | Hooks.onOperatorDebug(); | Debugging Reactive Streams with Hooks and Logging |
Key takeaways
buffer(), and flatMap with concurrency limits to prevent fast producers from overwhelming slow consumers.Interview Questions on This Topic
Explain the difference between Spring MVC and Spring WebFlux. When would you choose one over the other?
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
That's Spring Boot. Mark it forged?
5 min read · try the examples if you haven't