“I’ve heard of WebFlux, but what makes it different from Spring MVC?” — that’s a common question. Reading through conceptual explanations alone rarely makes things click, so this article starts from why it’s needed and walks you through the ideas hands-on with working code.

What Is Reactive Programming?

Spring MVC operates on a one thread per request model. While waiting on I/O — like database queries or external API calls — that thread simply sits idle. As requests increase, so must threads, and once the thread pool is exhausted, new requests can no longer be accepted.

Non-blocking I/O addresses this “just waiting” problem. While I/O is delegated to the OS, the same thread can handle other requests in the meantime. The key advantage is handling a large number of concurrent requests with far fewer threads.

Reactive programming is a programming style that pairs well with non-blocking I/O — it treats data as streams and chains operations declaratively. The foundation library for Spring WebFlux is Project Reactor, and its two core types are Mono and Flux.

Spring MVC vs. Spring WebFlux

This isn’t a question of which is better — each has its strengths.

Spring MVCSpring WebFlux
Processing modelBlocking, synchronousNon-blocking, asynchronous
ServerTomcat (default)Netty (default)
Thread countScales with request countSmall (roughly equal to CPU cores)
Programming modelImperativeReactive, functional
Primary use casesGeneral web apps, CRUDHigh-concurrency I/O, streaming

WebFlux is well-suited for API gateways with heavy I/O waits to external APIs or databases, inter-microservice communication, and streaming responses for real-time data. On the other hand, if your application is centered around JDBC-based RDBMS operations, or your team is unfamiliar with reactive programming, sticking with Spring MVC is perfectly reasonable.

Setup

Just add spring-boot-starter-webflux. Avoid having it coexist with spring-boot-starter-web (Tomcat) — if both are present, Netty and Tomcat will conflict.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
    // Do NOT add spring-boot-starter-web
}

application.properties can stay minimal. If the startup log shows Netty started on port 8080, you’re good to go.

Mono — A Stream of 0 or 1 Element

Mono<T> is a container that asynchronously returns at most one element. If you’re coming from Spring MVC, think of it as similar to CompletableFuture<T>.

// Basic creation and transformation
Mono<String> mono = Mono.just("Hello")
    .map(s -> s.toUpperCase())           // Synchronous value transformation
    .flatMap(s -> Mono.just(s + "!"));   // Chaining async operations that return another Mono

// Empty and error cases
Mono<String> empty = Mono.empty();
Mono<String> error = Mono.error(new RuntimeException("Something went wrong"));

Use map() for synchronous value transformations and flatMap() for chaining asynchronous operations that return another Mono.

Important: never use block() in production code. It breaks the reactive chain and blocks the thread, completely negating the benefits of WebFlux. Treat it as something reserved for test code only.

Flux — A Stream of 0 or More Elements

Flux<T> is a stream that asynchronously emits zero or more elements.

Flux<String> flux = Flux.fromIterable(List.of("Apple", "Banana", "Cherry"))
    .map(String::toUpperCase);

// Take only the first 2 elements
Flux<String> top2 = flux.take(2);

// Collect all elements into a list
Mono<List<String>> listMono = flux.collectList();

You can use take() to limit the number of elements, or collectList() to gather all elements together. Note that collectList() accumulates all elements in memory, so use caution with large datasets.

Creating Endpoints with RouterFunction

In addition to @RestController, WebFlux offers a functional-style routing API called RouterFunction. Its defining feature is the separation of routing definitions from handler logic.

@Configuration
public class UserRouter {

    @Bean
    public RouterFunction<ServerResponse> routes(UserHandler handler) {
        return RouterFunctions.route()
            .GET("/users/{id}", handler::getUser)
            .POST("/users", handler::createUser)
            .build();
    }
}
@Component
public class UserHandler {

    public Mono<ServerResponse> getUser(ServerRequest request) {
        String id = request.pathVariable("id");
        User user = new User(id, "Alice");
        return ServerResponse.ok()
            .contentType(MediaType.APPLICATION_JSON)
            .body(Mono.just(user), User.class);
    }
}

The annotation-based @RestController approach also works as-is in a WebFlux environment. If your team is comfortable with annotations, starting there is perfectly fine.

About WebClient

To send HTTP requests in a WebFlux environment, use WebClient. The traditional RestTemplate is blocking and therefore not recommended in WebFlux.

WebClient client = WebClient.create("https://api.example.com");

Mono<String> result = client.get()
    .uri("/items/1")
    .retrieve()
    .bodyToMono(String.class);

For a detailed guide on WebClient, see RestTemplate vs. WebClient Comparison Guide.

Should You Adopt WebFlux?

Good fits

  • API gateways with many outbound requests to external APIs
  • Frequent inter-microservice communication
  • Server-sent events (SSE) or WebSocket support
  • Streaming responses

Poor fits

  • Applications centered on JDBC-based relational database operations (migration to R2DBC can be costly)
  • Teams unfamiliar with reactive programming
  • Partial adoption into an existing Spring MVC application (switching to Netty is required, which is generally not recommended)

The basic principle is to choose between Spring MVC and WebFlux at the project level. Mixing them in the same project leads to Netty/Tomcat conflicts and unexpected behavior.

WebFlux is sometimes compared with Spring MVC’s @Async from an asynchronous processing perspective, but the two differ significantly in their threading models and scope of applicability. See the Spring Boot Asynchronous Processing Guide for more.

Summary

WebFlux is not a “replacement for Spring MVC” — it’s a distinct choice tailored for high-concurrency I/O workloads. Once you have a grasp of the basic Mono/Flux operations and how to write RouterFunctions, you’ll find it surprisingly approachable.

A good first step is to build one simple GET endpoint and experience the stream flow firsthand. When you’re ready to move on to database integration, don’t forget to check R2DBC support for your use case.