Week 14: Reactive Programming with WebFlux

Everything through Week 13 has used Spring MVC's blocking, one-thread-per-request model — and that's the right default for most APIs. This week introduces Spring's alternative non-blocking, reactive stack, which you already got a brief taste of back in Week 9's WebClient. You'll see when the added complexity is actually worth it, and what it looks like to build a fully reactive endpoint end to end.

Module 11 of 12 Week 14 of 15 ~4–5 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Understand Project Reactor's Mono and Flux and the reactive programming model behind them
  • Build a reactive REST controller and access data reactively with R2DBC
  • Know when WebFlux is actually worth choosing over Spring MVC — and when it isn't

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 vs. reactive — same idea
// 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.
You've already used Reactor once

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 & Flux basics
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:

core operators
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.

Never call .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.

pom.xml
<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:

Week 3, Spring MVC (blocking)
@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
    }
}
Week 14, Spring WebFlux (non-blocking)
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:

pom.xml
<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>
Task.java — R2DBC entity
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) {}
TaskRepository.java
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.

JPA and R2DBC don't mix

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.

a consumer pulling elements at its own pace
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.

Why this course defaults to Spring MVC

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

Hands-on

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:

  1. Create a new module with spring-boot-starter-webflux and spring-boot-starter-data-r2dbc, pointed at the same task table used in Weeks 4 and 5. Define the R2DBC Task record and a TaskRepository extends ReactiveCrudRepository<Task, Long>.
  2. Build a GET /api/tasks/feed endpoint returning Flux<TaskResponse> that streams every task from the repository, mapped to a response DTO.
  3. 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 use WebClient-style reactive chaining with flatMap to attach its tags to each task before it's returned from the feed.
  4. Deliberately break it: inside the flatMap (or the controller method), call a blocking JDBC/JPA repository method — for example, a leftover JpaTaskRepository.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.
  5. 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.
Hint

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?

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?

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?

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?

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.