1. Why Reactive & Project Reactor Basics
Spring MVC's default model is thread-per-request: every incoming HTTP request is handed a thread from a pool, and that thread stays occupied — blocked — for the entire duration of the request, including any time spent waiting on a database query, a downstream HTTP call, or disk I/O. For typical CRUD workloads this is fine: threads are cheap enough, and requests finish quickly. It stops being fine when a service handles a very large number of concurrent, slow, I/O-bound requests — imagine thousands of clients each waiting on a chain of downstream calls that each take a few hundred milliseconds. Thread-per-request either exhausts the thread pool or forces it so large that context-switching overhead eats the gains.
Reactive programming solves this by making I/O non-blocking: instead of a thread sitting idle while waiting for a result, it's released back to a small, fixed pool and reused for other work. When the result eventually arrives, a callback resumes the original logic — potentially on a different thread. This lets a handful of threads service a very large number of concurrent connections, at the cost of writing code declaratively rather than as a straight top-to-bottom sequence of statements.
Project Reactor is the reactive library Spring builds on (it implements the Reactive Streams specification, the same interop contract used by RxJava and Akka Streams). Rather than writing imperative code — "do this, then do that" — you build a pipeline of operators that describe what should happen to data as it becomes available, and Reactor drives that pipeline for you:
// Imperative (Spring MVC) -- runs top to bottom, blocking on findById
Task task = taskRepository.findById(id); // thread blocks here
TaskResponse response = toResponse(task); // thread resumes, then runs this
return response;
// Reactive (Reactor) -- describes a pipeline, doesn't run anything yet
Mono<TaskResponse> response = taskRepository.findById(id)
.map(this::toResponse);
// Nothing has executed. The pipeline runs only once something subscribes to it.
Week 9's WebClient was your first taste of this model — every call
returned a Mono<T> that you chained with .map() and
.flatMap() instead of blocking for a result. Everything below is that
same operator style, applied to an entire application instead of just one HTTP
client.
2. Mono & Flux
Reactor's whole API is built around two publisher types. Mono<T>
represents zero or one value, arriving asynchronously — the reactive
equivalent of Optional<T>. Flux<T> represents
zero to many values, arriving over time — the reactive equivalent of
List<T>. The comparison is useful for building intuition, but it
only goes so far: an Optional or List already holds its
value the moment you have a reference to it, while a Mono or
Flux is a description of a value (or stream of values) that
doesn't exist yet and won't be computed at all unless something subscribes to it.
Mono<TaskResponse> one = Mono.just(new TaskResponse(1L, "Ship reactive lesson", false));
Flux<TaskResponse> many = Flux.just(
new TaskResponse(1L, "Write proposal", false),
new TaskResponse(2L, "Review PR", true),
new TaskResponse(3L, "Deploy service", false)
);
The core operators mirror what you'd expect from Stream, with one crucial
difference for flatMap:
Flux<String> titles = many
.filter(task -> !task.completed()) // keep only incomplete tasks
.map(task -> task.title().toUpperCase()); // synchronous transform, 1-to-1
Flux<TaskResponse> enriched = many
.flatMap(task -> enrichmentClient.fetchTags(task.id()) // returns Mono<List<String>>
.map(tags -> task.withTags(tags))); // async transform, may reorder
Use map when the transformation is a plain synchronous function. Use
flatMap when the transformation itself returns another
Mono or Flux — typically because it involves another
asynchronous call, like a downstream HTTP request or a reactive repository lookup.
Reaching for map on something that returns a publisher gives you a
Flux<Mono<X>>, which is almost never what you want.
Subscribing
A Mono or Flux does nothing until it's subscribed
to — that's what actually triggers the pipeline to run. In standalone code
you'd call .subscribe() yourself; in a Spring WebFlux controller, the
framework subscribes on your behalf once you return the publisher, then streams the
result back to the HTTP client as it arrives. That's exactly why controller methods
return Mono<TaskResponse> or Flux<TaskResponse>
instead of calling .block() and returning a plain TaskResponse.
.block() in a reactive chain
Mono and Flux both expose a .block() method
that waits synchronously for the result — and calling it anywhere inside a reactive
pipeline defeats the entire point. It ties up one of Reactor's small, shared event
loop threads while it waits, which can stall every other request being processed by
that same thread. Reactive code has to be non-blocking all the way down, or
it isn't reactive at all.
3. Reactive REST Controllers
To build reactive endpoints, swap spring-boot-starter-web for
spring-boot-starter-webflux (or add both if the app needs to serve some
blocking endpoints alongside reactive ones — though mixing them in the same
application is unusual outside a migration). WebFlux replaces the embedded Tomcat
servlet container with a non-blocking server, Netty by default.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
A reactive controller looks almost identical to the Week 3 MVC one — same annotations,
same routes — except the method signatures return Mono<T> or
Flux<T> instead of the object directly:
@RestController
@RequestMapping("/api/tasks")
class TaskController {
private final TaskRepository taskRepository;
TaskController(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
@GetMapping
List<TaskResponse> listTasks() {
return taskRepository.findAll().stream() // blocks until the query returns
.map(this::toResponse)
.toList();
}
@GetMapping("/{id}")
TaskResponse getTask(@PathVariable Long id) {
return toResponse(taskRepository.findById(id)
.orElseThrow(TaskNotFoundException::new)); // blocks
}
}
package com.codeverse.week14;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/api/tasks")
class TaskController {
private final TaskRepository taskRepository;
TaskController(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
@GetMapping
Flux<TaskResponse> listTasks() {
return taskRepository.findAll()
.map(this::toResponse); // no .stream(), no blocking -- just a pipeline
}
@GetMapping("/{id}")
Mono<TaskResponse> getTask(@PathVariable Long id) {
return taskRepository.findById(id)
.map(this::toResponse)
.switchIfEmpty(Mono.error(new TaskNotFoundException(id)));
}
private TaskResponse toResponse(Task task) {
return new TaskResponse(task.id(), task.title(), task.completed());
}
}
Route mapping, path variables and exception handling with
@ControllerAdvice all still work the same way you learned in Week 3 —
WebFlux reuses most of Spring MVC's annotation model. What's different is entirely
inside the method bodies: no .stream(), no .orElseThrow()
on an Optional that's already been fetched, no thread sitting blocked
while the database responds.
4. Reactive Data Access with R2DBC
There's a catch with the controller above: taskRepository.findAll() only
returns a Flux<Task> if the repository underneath is reactive.
The Spring Data JPA repositories you built in Weeks 4 and 5 are fundamentally
blocking — JDBC, the driver protocol JPA sits on, was designed around
synchronous, blocking calls, and there's no way to make a JPA
CrudRepository method return a Mono or Flux
that behaves non-blockingly. Calling a JPA repository from inside a WebFlux controller
still works syntactically, but it blocks one of Reactor's small pool of event-loop
threads for the duration of the query — silently reintroducing the exact problem
reactive programming exists to avoid.
R2DBC (Reactive Relational Database Connectivity) is a separate
driver specification built for non-blocking database access from the ground up, with
its own driver per database (r2dbc-postgresql, r2dbc-mysql,
and so on). Spring Data R2DBC gives you a ReactiveCrudRepository
interface that's the reactive counterpart to the CrudRepository you've
used since Week 4:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-r2dbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>r2dbc-postgresql</artifactId>
<scope>runtime</scope>
</dependency>
package com.codeverse.week14;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
@Table("task")
record Task(@Id Long id, String title, boolean completed) {}
package com.codeverse.week14;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Flux;
interface TaskRepository extends ReactiveCrudRepository<Task, Long> {
Flux<Task> findByCompletedFalse();
}
findAll(), findById() and derived query methods work almost
identically to their JPA equivalents — the difference is entirely in the return
types: Mono<Task> and Flux<Task> instead of
Optional<Task> and List<Task>. What R2DBC
intentionally does not give you is a JPA-style persistence context: no
lazy-loaded associations, no automatic dirty checking, no first-level cache. Every
relationship has to be fetched explicitly and mapped by hand — R2DBC trades away that
convenience specifically because a persistence context relies on tracking object state
in ways that don't translate to a non-blocking model.
The TaskRepository extends JpaRepository<Task, Long> from Weeks
4 and 5 and the TaskRepository extends ReactiveCrudRepository<Task, Long>
above are two entirely separate stacks talking to the same table over two different
drivers (JDBC vs. R2DBC). A reactive application has to pick R2DBC end to end for
anything on the request path — bolting a JPA call onto a reactive controller doesn't
partially work, it quietly blocks.
5. Backpressure
A Flux can produce values faster than its consumer can process them —
a database returning thousands of rows per second into a handler that writes each one
to a slow downstream API, for instance. Without a way to slow the producer down, the
consumer either falls behind indefinitely, buffering values in memory until it runs
out, or values get dropped.
Backpressure is the mechanism the Reactive Streams specification
builds in to prevent exactly this: rather than a producer pushing values as fast as it
can, the consumer (the Subscriber) explicitly requests a
number of elements at a time via Subscription.request(n), and the producer
(the Publisher) is required to send no more than that. It's not an
add-on feature — it's a first-class part of the contract every Reactive Streams
publisher and subscriber must honor, which is exactly what makes it safe to build
reactive pipelines without every consumer needing its own manual throttling logic.
package com.codeverse.week14;
import org.reactivestreams.Subscription;
import reactor.core.publisher.BaseSubscriber;
import reactor.core.publisher.Flux;
class SlowConsumerDemo {
void run() {
Flux.range(1, 1_000)
.doOnRequest(n -> System.out.println("Producer: " + n + " requested"))
.subscribe(new BaseSubscriber<Integer>() {
@Override
protected void hookOnSubscribe(Subscription subscription) {
request(10); // ask for only the first 10 elements
}
@Override
protected void hookOnNext(Integer value) {
writeToSlowDownstream(value);
if (value % 10 == 0) {
request(10); // ask for the next batch once this one is handled
}
}
private void writeToSlowDownstream(Integer value) {
System.out.println("Consumer: processing " + value);
}
});
}
}
Most application code never touches request(n) directly — operators like
.buffer(), .limitRate(), and the default prefetch behavior
of flatMap handle it for you. What matters is knowing the mechanism
exists: it's the reason a reactive pipeline reading a huge result set doesn't need to
be manually rate-limited to avoid running the application out of memory the way an
unbounded producer/consumer setup would.
6. WebFlux vs. Spring MVC — Choosing
WebFlux earns its added complexity under a specific profile: high-concurrency, I/O-bound workloads — a service fielding a large number of simultaneous requests, most of which spend their time waiting on slow downstream calls, rather than doing CPU-heavy work. Think of the service-to-service patterns from Week 9 and the message-driven systems from Week 10: a gateway fanning out to several slow APIs per request, or a service consuming a high-throughput stream, benefits from a stack that's non-blocking top to bottom — controller, service layer, and data access all reactive.
That last part is the catch. WebFlux only pays off when nothing in the request path blocks — the moment a reactive controller calls into a blocking JPA repository (as Section 4 covered), you've paid for the extra complexity of reactive code without getting the non-blocking benefit it exists for. For the typical CRUD API this course has spent thirteen weeks building — moderate concurrency, request handling dominated by a single database round-trip, a team more comfortable reading straight-line code — Spring MVC remains the right default, especially once Spring Data JPA is already in the mix, since JPA's persistence-context conveniences (lazy loading, dirty checking) have no reactive equivalent to fall back on.
Every other week in this curriculum builds on Spring MVC and Spring Data JPA on purpose — it's the stack most Spring Boot teams actually run, and it's simpler to reason about, test, and debug. WebFlux is a specialized tool for a specific class of problem, not a strictly "more modern" replacement for MVC. Reach for it when you can point to a concrete concurrency or I/O bottleneck MVC is actually causing — not by default.
7. Hands-on Exercise
Build a reactive task feed — and deliberately break it first
Stand up a small, read-only WebFlux service over the same task table from Weeks 4 and 5, enrich each task with a reactive downstream call, then intentionally reintroduce a blocking call so you can see — and fix — exactly what Section 4 warned about.
Requirements:
- Create a new module with
spring-boot-starter-webfluxandspring-boot-starter-data-r2dbc, pointed at the sametasktable used in Weeks 4 and 5. Define the R2DBCTaskrecord and aTaskRepository extends ReactiveCrudRepository<Task, Long>. - Build a
GET /api/tasks/feedendpoint returningFlux<TaskResponse>that streams every task from the repository, mapped to a response DTO. - Add a mocked downstream "enrichment" service exposing a
Mono<List<String>> fetchTags(Long taskId)method (an in-memory stub is fine — no real HTTP call required), and useWebClient-style reactive chaining withflatMapto attach its tags to each task before it's returned from the feed. - Deliberately break it: inside the
flatMap(or the controller method), call a blocking JDBC/JPA repository method — for example, a leftoverJpaTaskRepository.findById(id)from Week 4 — instead of the reactive one, and confirm it still compiles and "works." Note in a comment why this quietly defeats the point of using WebFlux at all, per Section 4. - Fix it: replace the blocking call with the reactive
TaskRepository/enrichment-service equivalent so the entire chain, from controller to database, is non-blocking end to end.
You won't easily see the blocking call fail — it'll still return correct data. The point of step 4 is to notice, by inspection, exactly where a thread would stall, not to wait for a crash. If you want to prove it to yourself, try firing several concurrent requests at the broken version versus the fixed version and compare response latency under load.
8. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Beyond "the reactive version of," what's the core difference between Mono/Flux and Optional/List?
Beyond "the reactive version of," what's the core difference between Mono/Flux and Optional/List?
An Optional or List already holds its value (or
values) at the moment you have a reference to it — the work to produce that data
already happened. A Mono or Flux is a description
of a value, or stream of values, that hasn't been computed yet and won't be
computed at all unless something subscribes to it. That's why chaining
.map() onto a Mono doesn't run anything immediately —
it just builds up the pipeline that will run later, once subscribed, potentially
asynchronously and on a different thread than the one that built it.
Q2
Why does calling a blocking Spring Data JPA repository inside a reactive chain defeat the point of using WebFlux?
Why does calling a blocking Spring Data JPA repository inside a reactive chain defeat the point of using WebFlux?
WebFlux's entire performance case rests on serving a large number of concurrent requests from a small, fixed pool of event-loop threads by never letting those threads sit idle waiting on I/O. JPA runs over JDBC, a synchronous, blocking driver protocol — calling it from inside a reactive pipeline ties up one of those scarce event-loop threads for the full duration of the query, exactly the way Spring MVC's thread-per-request model would. It compiles and returns correct data, but it silently reintroduces the blocking behavior WebFlux was adopted to avoid, while still paying reactive code's added complexity cost.
Q3
What does backpressure mean in Reactive Streams, and why does it matter?
What does backpressure mean in Reactive Streams, and why does it matter?
Backpressure is the mechanism by which a consumer (Subscriber)
explicitly requests a bounded number of elements at a time from a producer
(Publisher) via Subscription.request(n), rather than
the producer pushing values as fast as it can generate them. It matters because
without it, a fast producer feeding a slow consumer — a database streaming rows
faster than a handler can write them downstream, for example — would either
buffer unboundedly until memory runs out or start dropping values. Because
backpressure is a required part of the Reactive Streams contract rather than an
opt-in feature, every conforming publisher and subscriber cooperates on it
automatically, which is why most application code never has to implement rate
limiting by hand.
Q4
What concrete signals in a real project would justify choosing WebFlux over Spring MVC?
What concrete signals in a real project would justify choosing WebFlux over Spring MVC?
The strongest signal is high concurrency combined with I/O-bound request handling — a service fielding a large number of simultaneous requests where each request spends most of its time waiting on slow downstream calls (multiple chained service-to-service calls, a high-throughput message stream, or many external APIs per request) rather than doing CPU-bound work. Just as important is whether the entire request path can realistically be made non-blocking — data access included, via R2DBC instead of JPA — since a reactive controller sitting on top of a blocking repository gets none of the benefit while still paying the complexity cost. Absent both of those, a typical CRUD API with moderate concurrency and JPA already in place is better served by sticking with Spring MVC.