Reactive Programming with Spring WebFlux and Project Reactor: A Complete Guide
Introduction
Traditional Spring MVC applications rely on a thread-per-request model: every incoming HTTP request occupies a thread from the pool until the response is sent. This model works fine at low concurrency, but under heavy I/O load — database calls, external API calls, file reads — those threads spend most of their time waiting. Threads are expensive, so the application stops scaling long before the hardware runs out.
Reactive programming turns this model upside down. Instead of blocking a thread while waiting for I/O, reactive code registers a callback and releases the thread immediately. A tiny pool of event-loop threads can handle thousands of concurrent requests because no thread ever sits idle. Spring WebFlux, introduced in Spring 5, brings this approach to the Spring ecosystem, built on top of Project Reactor — the reactive library that provides the Mono and Flux types.
This guide walks you through building a complete reactive task-management REST API from scratch. By the end, you will understand:
- The
MonoandFluxtypes and their most useful operators - Functional routing with
RouterFunctionandHandlerFunction - Reactive persistence with Spring Data MongoDB Reactive
- Non-blocking HTTP calls with
WebClient - Reactive error handling
- Testing reactive pipelines with
StepVerifierandWebTestClient
Prerequisites
- Java 17 or later
- Apache Maven 3.9+
- Docker (to run MongoDB locally via a container)
- A basic understanding of Spring Boot and REST APIs
- Familiarity with lambda expressions and the Java Streams API is helpful but not required
Project Setup
Generate a new project at start.spring.io or copy the pom.xml below. The key dependency is spring-boot-starter-webflux, which pulls in Project Reactor, Netty (the non-blocking HTTP server), and the reactive WebClient.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.3</version>
<relativePath/>
</parent>
<groupId>com.vimleshpandey.demo</groupId>
<artifactId>reactive-tasks</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>reactive-tasks</name>
<description>Reactive task management API with Spring WebFlux</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<!-- Reactive web (includes Netty, Reactor, WebClient) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- Reactive MongoDB -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb-reactive</artifactId>
</dependency>
<!-- Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Test dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Embedded MongoDB for testing -->
<dependency>
<groupId>de.flapdoodle.embed</groupId>
<artifactId>de.flapdoodle.embed.mongo.spring3x</artifactId>
<version>4.17.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
Add src/main/resources/application.yml:
spring:
application:
name: reactive-tasks
data:
mongodb:
uri: mongodb://localhost:27017/tasks_db
auto-index-creation: true
server:
port: 8080
logging:
level:
org.springframework.data.mongodb: DEBUG
reactor.netty: INFO
Start MongoDB with Docker:
docker run -d --name mongodb -p 27017:27017 mongo:7
Understanding Mono and Flux
Project Reactor provides two fundamental reactive types:
| Type | Represents | Analogy |
|---|---|---|
Mono | 0 or 1 element | Optional but async |
Flux | 0 to N elements | Stream but async |
Neither type executes anything on its own — they are blueprints for a data pipeline. Execution only starts when a subscriber attaches. In a WebFlux application, the framework is the subscriber; you just return Mono or Flux from your handler methods.
// A Mono that wraps a single value
Mono<String> mono = Mono.just("Hello, Reactor!");
// A Flux that emits three integers
Flux<Integer> flux = Flux.just(1, 2, 3);
// Nothing happened yet — no subscriber
// This is where execution begins:
mono.subscribe(System.out::println);
flux.subscribe(System.out::println);
Key Operators
Reactor ships with over 200 operators. The ones you will use in nearly every project:
// map — synchronous 1:1 transformation
Flux.just("alice", "bob")
.map(String::toUpperCase) // ALICE, BOB
// flatMap — async 1:N transformation (returns another Mono/Flux)
Flux.just("alice", "bob")
.flatMap(name -> fetchUserFromDb(name)) // parallel by default
// filter — keep elements matching predicate
Flux.range(1, 10)
.filter(n -> n % 2 == 0) // 2, 4, 6, 8, 10
// zip — combine two publishers element-by-element
Mono<String> name = Mono.just("Alice");
Mono<Integer> age = Mono.just(30);
Mono<String> combined = Mono.zip(name, age)
.map(t -> t.getT1() + " is " + t.getT2());
// switchIfEmpty — fallback when the source is empty
Mono.empty()
.switchIfEmpty(Mono.just("default"));
// onErrorResume — recover from errors
Mono.error(new RuntimeException("oops"))
.onErrorResume(ex -> Mono.just("recovered"));
Domain Model
Create the Task document and a simple enum for status:
// src/main/java/com/vimleshpandey/demo/model/TaskStatus.java
package com.vimleshpandey.demo.model;
public enum TaskStatus {
PENDING, IN_PROGRESS, DONE
}
// src/main/java/com/vimleshpandey/demo/model/Task.java
package com.vimleshpandey.demo.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "tasks")
public class Task {
@Id
private String id;
private String title;
private String description;
@Builder.Default
private TaskStatus status = TaskStatus.PENDING;
private String assignee;
@CreatedDate
private Instant createdAt;
@LastModifiedDate
private Instant updatedAt;
}
Add a DTO for incoming requests to keep validation separate from persistence:
// src/main/java/com/vimleshpandey/demo/dto/TaskRequest.java
package com.vimleshpandey.demo.dto;
import com.vimleshpandey.demo.model.TaskStatus;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class TaskRequest {
@NotBlank(message = "Title must not be blank")
@Size(max = 200, message = "Title must be 200 characters or fewer")
private String title;
@Size(max = 2000)
private String description;
private TaskStatus status;
private String assignee;
}
Reactive Repository
Spring Data Reactive MongoDB provides ReactiveMongoRepository, which returns Mono and Flux instead of plain objects and collections:
// src/main/java/com/vimleshpandey/demo/repository/TaskRepository.java
package com.vimleshpandey.demo.repository;
import com.vimleshpandey.demo.model.Task;
import com.vimleshpandey.demo.model.TaskStatus;
import org.springframework.data.mongodb.repository.ReactiveMongoRepository;
import reactor.core.publisher.Flux;
public interface TaskRepository extends ReactiveMongoRepository<Task, String> {
Flux<Task> findByStatus(TaskStatus status);
Flux<Task> findByAssignee(String assignee);
Flux<Task> findByTitleContainingIgnoreCase(String keyword);
}
No implementation needed — Spring Data generates it at runtime, just like in the non-reactive world.
Service Layer
The service layer is where most of the reactive operator composition happens:
// src/main/java/com/vimleshpandey/demo/service/TaskService.java
package com.vimleshpandey.demo.service;
import com.vimleshpandey.demo.dto.TaskRequest;
import com.vimleshpandey.demo.model.Task;
import com.vimleshpandey.demo.model.TaskStatus;
import com.vimleshpandey.demo.repository.TaskRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@Slf4j
@Service
@RequiredArgsConstructor
public class TaskService {
private final TaskRepository taskRepository;
public Flux<Task> findAll() {
return taskRepository.findAll()
.doOnSubscribe(s -> log.debug("Fetching all tasks"));
}
public Flux<Task> findByStatus(TaskStatus status) {
return taskRepository.findByStatus(status);
}
public Mono<Task> findById(String id) {
return taskRepository.findById(id)
.switchIfEmpty(Mono.error(
new TaskNotFoundException("Task not found: " + id)));
}
public Mono<Task> create(TaskRequest request) {
Task task = Task.builder()
.title(request.getTitle())
.description(request.getDescription())
.status(request.getStatus() != null ? request.getStatus() : TaskStatus.PENDING)
.assignee(request.getAssignee())
.build();
return taskRepository.save(task)
.doOnSuccess(t -> log.info("Created task: {}", t.getId()));
}
public Mono<Task> update(String id, TaskRequest request) {
return findById(id)
.flatMap(existing -> {
existing.setTitle(request.getTitle());
existing.setDescription(request.getDescription());
if (request.getStatus() != null) {
existing.setStatus(request.getStatus());
}
if (request.getAssignee() != null) {
existing.setAssignee(request.getAssignee());
}
return taskRepository.save(existing);
});
}
public Mono<Void> delete(String id) {
return findById(id)
.flatMap(taskRepository::delete)
.doOnSuccess(v -> log.info("Deleted task: {}", id));
}
public Flux<Task> search(String keyword) {
return taskRepository.findByTitleContainingIgnoreCase(keyword);
}
}
Add the custom exception:
// src/main/java/com/vimleshpandey/demo/service/TaskNotFoundException.java
package com.vimleshpandey.demo.service;
public class TaskNotFoundException extends RuntimeException {
public TaskNotFoundException(String message) {
super(message);
}
}
Functional Routing
Spring WebFlux supports two programming models: the familiar @RestController annotations and the functional model with RouterFunction + HandlerFunction. The functional style is more explicit and easier to test in isolation.
Start with the handler — it receives a ServerRequest and returns a Mono:
// src/main/java/com/vimleshpandey/demo/handler/TaskHandler.java
package com.vimleshpandey.demo.handler;
import com.vimleshpandey.demo.dto.TaskRequest;
import com.vimleshpandey.demo.model.TaskStatus;
import com.vimleshpandey.demo.service.TaskNotFoundException;
import com.vimleshpandey.demo.service.TaskService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.Validator;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Mono;
import java.net.URI;
@Component
@RequiredArgsConstructor
public class TaskHandler {
private final TaskService taskService;
private final Validator validator;
public Mono<ServerResponse> getAll(ServerRequest request) {
var statusParam = request.queryParam("status");
var tasks = statusParam
.map(s -> taskService.findByStatus(TaskStatus.valueOf(s.toUpperCase())))
.orElseGet(taskService::findAll);
return ServerResponse.ok().body(tasks, com.vimleshpandey.demo.model.Task.class);
}
public Mono<ServerResponse> getById(ServerRequest request) {
String id = request.pathVariable("id");
return taskService.findById(id)
.flatMap(task -> ServerResponse.ok().bodyValue(task))
.onErrorResume(TaskNotFoundException.class,
ex -> ServerResponse.notFound().build());
}
public Mono<ServerResponse> create(ServerRequest request) {
return request.bodyToMono(TaskRequest.class)
.flatMap(this::validate)
.flatMap(taskService::create)
.flatMap(task -> ServerResponse
.created(URI.create("/api/tasks/" + task.getId()))
.bodyValue(task));
}
public Mono<ServerResponse> update(ServerRequest request) {
String id = request.pathVariable("id");
return request.bodyToMono(TaskRequest.class)
.flatMap(this::validate)
.flatMap(req -> taskService.update(id, req))
.flatMap(task -> ServerResponse.ok().bodyValue(task))
.onErrorResume(TaskNotFoundException.class,
ex -> ServerResponse.notFound().build());
}
public Mono<ServerResponse> delete(ServerRequest request) {
String id = request.pathVariable("id");
return taskService.delete(id)
.then(ServerResponse.noContent().build())
.onErrorResume(TaskNotFoundException.class,
ex -> ServerResponse.notFound().build());
}
public Mono<ServerResponse> search(ServerRequest request) {
String keyword = request.queryParam("q").orElse("");
return ServerResponse.ok().body(
taskService.search(keyword), com.vimleshpandey.demo.model.Task.class);
}
private Mono<TaskRequest> validate(TaskRequest request) {
var errors = new BeanPropertyBindingResult(request, "taskRequest");
validator.validate(request, errors);
if (errors.hasErrors()) {
String message = errors.getFieldErrors().stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.reduce("", (a, b) -> a + "; " + b);
return Mono.error(new ResponseStatusException(
HttpStatus.BAD_REQUEST, message));
}
return Mono.just(request);
}
}
Now define the routes:
// src/main/java/com/vimleshpandey/demo/router/TaskRouter.java
package com.vimleshpandey.demo.router;
import com.vimleshpandey.demo.handler.TaskHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.web.reactive.function.server.RequestPredicates.*;
@Configuration
public class TaskRouter {
@Bean
public RouterFunction<ServerResponse> taskRoutes(TaskHandler handler) {
return RouterFunctions.route()
.path("/api/tasks", builder -> builder
.GET("", accept(APPLICATION_JSON), handler::getAll)
.GET("/search", accept(APPLICATION_JSON), handler::search)
.GET("/{id}", accept(APPLICATION_JSON), handler::getById)
.POST("", contentType(APPLICATION_JSON), handler::create)
.PUT("/{id}", contentType(APPLICATION_JSON), handler::update)
.DELETE("/{id}", handler::delete)
)
.build();
}
}
Global Error Handling
Centralise error responses with a WebExceptionHandler:
// src/main/java/com/vimleshpandey/demo/exception/GlobalErrorHandler.java
package com.vimleshpandey.demo.exception;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.core.io.buffer.DataBufferFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebExceptionHandler;
import reactor.core.publisher.Mono;
import java.util.Map;
@Slf4j
@Order(-2)
@Component
@RequiredArgsConstructor
public class GlobalErrorHandler implements WebExceptionHandler {
private final ObjectMapper objectMapper;
@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR;
String message = "An unexpected error occurred";
if (ex instanceof ResponseStatusException rse) {
status = HttpStatus.valueOf(rse.getStatusCode().value());
message = rse.getReason() != null ? rse.getReason() : rse.getMessage();
}
log.error("Request error [{}] {}: {}",
status.value(), exchange.getRequest().getPath(), ex.getMessage());
exchange.getResponse().setStatusCode(status);
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
DataBufferFactory bufferFactory = exchange.getResponse().bufferFactory();
try {
byte[] bytes = objectMapper.writeValueAsBytes(
Map.of("error", message, "status", status.value()));
return exchange.getResponse().writeWith(
Mono.just(bufferFactory.wrap(bytes)));
} catch (Exception e) {
return Mono.error(e);
}
}
}
Making Reactive HTTP Calls with WebClient
WebClient is the reactive, non-blocking replacement for RestTemplate. Imagine your tasks need to enrich data from an external service:
// src/main/java/com/vimleshpandey/demo/client/UserServiceClient.java
package com.vimleshpandey.demo.client;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import java.time.Duration;
@Slf4j
@Component
public class UserServiceClient {
private final WebClient webClient;
public UserServiceClient(WebClient.Builder builder) {
this.webClient = builder
.baseUrl("https://jsonplaceholder.typicode.com")
.build();
}
public Mono<String> getUserName(int userId) {
return webClient.get()
.uri("/users/{id}", userId)
.retrieve()
.bodyToMono(UserResponse.class)
.map(UserResponse::getName)
.timeout(Duration.ofSeconds(3))
.onErrorResume(ex -> {
log.warn("User service unavailable: {}", ex.getMessage());
return Mono.just("Unknown User");
});
}
// Inner record for deserialization
public record UserResponse(int id, String name, String email) {}
}
Register the WebClient.Builder bean in your main application class:
// src/main/java/com/vimleshpandey/demo/ReactiveTasksApplication.java
package com.vimleshpandey.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.data.mongodb.config.EnableReactiveMongoAuditing;
import org.springframework.web.reactive.function.client.WebClient;
@SpringBootApplication
@EnableReactiveMongoAuditing
public class ReactiveTasksApplication {
public static void main(String[] args) {
SpringApplication.run(ReactiveTasksApplication.class, args);
}
@Bean
public WebClient.Builder webClientBuilder() {
return WebClient.builder();
}
}
Testing
Testing Reactive Pipelines with StepVerifier
StepVerifier (from reactor-test) lets you test reactive chains step by step:
// src/test/java/com/vimleshpandey/demo/service/TaskServiceTest.java
package com.vimleshpandey.demo.service;
import com.vimleshpandey.demo.dto.TaskRequest;
import com.vimleshpandey.demo.model.Task;
import com.vimleshpandey.demo.model.TaskStatus;
import com.vimleshpandey.demo.repository.TaskRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class TaskServiceTest {
@Mock
private TaskRepository taskRepository;
@InjectMocks
private TaskService taskService;
@Test
void findAll_returnsAllTasks() {
Task t1 = Task.builder().id("1").title("Task One").build();
Task t2 = Task.builder().id("2").title("Task Two").build();
when(taskRepository.findAll()).thenReturn(Flux.just(t1, t2));
StepVerifier.create(taskService.findAll())
.expectNext(t1)
.expectNext(t2)
.verifyComplete();
}
@Test
void findById_whenNotFound_emitsError() {
when(taskRepository.findById("missing")).thenReturn(Mono.empty());
StepVerifier.create(taskService.findById("missing"))
.expectErrorMatches(ex ->
ex instanceof TaskNotFoundException &&
ex.getMessage().contains("missing"))
.verify();
}
@Test
void create_savesAndReturnsTask() {
TaskRequest req = new TaskRequest();
req.setTitle("New Task");
req.setDescription("Do something");
Task saved = Task.builder().id("abc").title("New Task").build();
when(taskRepository.save(any(Task.class))).thenReturn(Mono.just(saved));
StepVerifier.create(taskService.create(req))
.expectNextMatches(t -> "abc".equals(t.getId()))
.verifyComplete();
}
}
Integration Testing with WebTestClient
WebTestClient is the reactive equivalent of MockMvc:
// src/test/java/com/vimleshpandey/demo/TaskIntegrationTest.java
package com.vimleshpandey.demo;
import com.vimleshpandey.demo.dto.TaskRequest;
import com.vimleshpandey.demo.model.Task;
import com.vimleshpandey.demo.model.TaskStatus;
import com.vimleshpandey.demo.repository.TaskRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient
class TaskIntegrationTest {
@Autowired
private WebTestClient webTestClient;
@Autowired
private TaskRepository taskRepository;
@BeforeEach
void setUp() {
taskRepository.deleteAll().block();
}
@Test
void createTask_returns201() {
TaskRequest req = new TaskRequest();
req.setTitle("Write integration tests");
req.setDescription("Cover all endpoints");
webTestClient.post().uri("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(req)
.exchange()
.expectStatus().isCreated()
.expectBody(Task.class)
.value(task -> {
assert task.getId() != null;
assert "Write integration tests".equals(task.getTitle());
assert task.getStatus() == TaskStatus.PENDING;
});
}
@Test
void getTask_whenNotFound_returns404() {
webTestClient.get().uri("/api/tasks/nonexistent")
.exchange()
.expectStatus().isNotFound();
}
@Test
void listTasks_filtersByStatus() {
taskRepository.save(
Task.builder().title("Done task").status(TaskStatus.DONE).build()
).block();
taskRepository.save(
Task.builder().title("Pending task").status(TaskStatus.PENDING).build()
).block();
webTestClient.get().uri("/api/tasks?status=DONE")
.exchange()
.expectStatus().isOk()
.expectBodyList(Task.class)
.hasSize(1)
.value(list -> assert "Done task".equals(list.get(0).getTitle()));
}
}
Running the Application
With Docker running MongoDB, start the application:
mvn spring-boot:run
You should see Netty start on port 8080 instead of Tomcat — that is the non-blocking server:
Started ReactiveTasksApplication in 2.341 seconds (process running for 2.7)
Netty started on port 8080
Test with curl:
# Create a task
curl -X POST http://localhost:8080/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Learn WebFlux", "description": "Build something reactive"}'
# List all tasks
curl http://localhost:8080/api/tasks
# Filter by status
curl http://localhost:8080/api/tasks?status=PENDING
# Update a task (replace ID with the actual one)
curl -X PUT http://localhost:8080/api/tasks/64f3abc123def \
-H "Content-Type: application/json" \
-d '{"title": "Learn WebFlux", "status": "DONE"}'
# Search tasks
curl http://localhost:8080/api/tasks/search?q=webflux
Common Pitfalls and Tips
1. Blocking calls inside reactive pipelines
This is the most dangerous mistake. Any call to .block(), Thread.sleep(), or a synchronous JDBC driver inside a reactive chain will starve the event loop and eliminate all performance benefits. Use reactive drivers for every I/O operation.
// WRONG — blocks the event-loop thread
.flatMap(id -> {
User user = jdbcRepository.findById(id); // blocking!
return Mono.just(user);
})
// RIGHT — use a reactive repository or offload to a bounded scheduler
.flatMap(id -> reactiveRepository.findById(id))
If you must call a blocking library, wrap it with Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic()) to run it on a thread pool designed for blocking work.
2. Forgetting to subscribe
Reactive pipelines are lazy. If you create a Mono or Flux but never subscribe to it (and the framework is not the subscriber), nothing happens. In a Spring WebFlux controller or functional route, always return the reactive type — do not call .block() or .subscribe() yourself.
3. Misusing flatMap vs map
Use map for synchronous transformations. Use flatMap when the transformation itself returns a Mono or Flux. Nesting map inside flatMap (or vice versa) creates Mono or Flux, which is almost never what you want.
4. Error handling in the middle of a chain
Place onErrorResume and onErrorReturn at the point in the chain where recovery is appropriate, not at the end as an afterthought. An error early in the chain propagates downstream unless caught.
5. Hot vs Cold publishers
All the publishers created with Mono.just() or Flux.just() are cold — they re-execute for each subscriber. A hot publisher (like a live stream from Kafka or a websocket) emits regardless of subscribers. Understanding this distinction matters when sharing a stream across multiple consumers.
Conclusion
Spring WebFlux and Project Reactor provide a powerful toolkit for building highly concurrent, I/O-bound applications on the JVM. The learning curve is real: thinking in reactive streams, understanding operators, and avoiding blocking code all require deliberate practice. But the payoff is an application that can handle orders of magnitude more concurrent requests on the same hardware.
The key concepts to carry forward:
Monofor single values,Fluxfor streams — both are lazy until subscribed- Chain operators (
map,flatMap,filter,zip) to compose async pipelines without callbacks - Functional routing with
RouterFunction+HandlerFunctiongives clean, testable endpoints WebClientis the correct HTTP client for reactive applications —RestTemplateis synchronous and should not be used in a WebFlux context- Test with
StepVerifierfor unit tests andWebTestClientfor integration tests - Never block inside a reactive pipeline unless you explicitly offload to
Schedulers.boundedElastic()
The complete source code for this tutorial is available as a downloadable ZIP above. Happy reactive coding!