DEV Community

Cover image for Spring WebFlux: When to Use It and How to Build With It
Adam - The Developer
Adam - The Developer

Posted on

Spring WebFlux: When to Use It and How to Build With It

Spring WebFlux promises to handle thousands of concurrent users while your code stays blissfully non-blocking. Sounds great, right?

Plot twist: it comes with a complexity tax, debugging becomes an archaeological expedition, and your brain needs a fundamental reboot.

The real question isn't "Is WebFlux cool?" — it absolutely is.
It's "Do I actually need this, or am I just chasing a sexy buzzword?"

Spoiler: most developers don't. If you're genuinely unsure, you definitely don't. This guide separates the reactive wizards from the reactive wishful thinkers—and shows you how to join the former camp (if you should).

TL;DR: Want to skip the theory? Check out the working example repo.


Is WebFlux Right for You? (Answer This First)

Before diving into code, ask yourself these questions:

You should probably use WebFlux if:

  • You have genuinely high concurrency needs (thousands of concurrent connections)
  • Your app spends most time waiting for I/O (database calls, HTTP requests to other services)
  • You're building microservices that need to make multiple non-blocking calls to other APIs
  • Your team already knows reactive programming

Stick with Spring MVC if:

  • You're building a CRUD app with typical traffic (< 1000 concurrent users)
  • Your bottleneck is database performance, not thread count
  • You use JDBC or legacy JPA and don't want to rewrite data access layers
  • Your team is comfortable with traditional imperative code
  • You have CPU-intensive operations (the threading model won't help there anyway)

The honest truth: Most teams don't need WebFlux. If you're uncertain, use Spring MVC. You can always migrate later if you actually hit concurrency limits.

What Is Spring WebFlux Really?

Spring WebFlux is a non-blocking, event-driven web framework built on Project Reactor. Instead of assigning one thread per request, it uses a small pool of threads and an event loop to handle thousands of requests.

The threading difference:

  • Spring MVC: 200 threads = ~200 concurrent requests before queuing
  • Spring WebFlux: 8-16 threads (CPU cores × 2) = thousands of concurrent requests efficiently.

The catch: WebFlux only helps if those requests spend time waiting for I/O. CPU-bound work still blocks your threads regardless of which framework you use.

Core Concepts: Mono and Flux

Everything in WebFlux returns either a Mono or Flux:

// Mono = 0 or 1 element (like Optional or Future)
Mono<User> user = userRepository.findById(1L);

// Flux = 0 to many elements (a stream)
Flux<User> users = userRepository.findAll();
Enter fullscreen mode Exit fullscreen mode

The key difference from traditional code: these don't execute immediately. They describe what will happen when someone subscribes. This lazy evaluation is what makes non-blocking code possible.

Let's not waste any more time and we'll get you set up with a quick, easy and simple CRUD app!

Building a WebFlux CRUD App

Project Setup

Add these to your pom.xml

    <dependencies>
        <!-- Spring WebFlux -->
        <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>

        <!-- Testing -->
        <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>

        <dependency>
            <groupId>de.flapdoodle.embed</groupId>
            <artifactId>de.flapdoodle.embed.mongo</artifactId>
            <version>4.6.1</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
Enter fullscreen mode Exit fullscreen mode

Domain Model

package com.example.webflux.model;

import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "users")
public class User {
    @Id
    private String id;

    @NotBlank(message = "Name is required")
    @Size(min = 2, max = 50, message = "Name must be between 2 and 50 characters")
    private String name;

    @NotBlank(message = "Email is required")
    @Email(message = "Email should be valid")
    private String email;

    @NotBlank(message = "Role is required")
    private String role;
}
Enter fullscreen mode Exit fullscreen mode

Repository Layer

package com.example.webflux.repository;

import org.springframework.data.mongodb.repository.ReactiveMongoRepository;
import org.springframework.stereotype.Repository;

import com.example.webflux.model.User;

import reactor.core.publisher.Mono;

@Repository
public interface UserRepository extends ReactiveMongoRepository<User, String> {
    Mono<User> findByEmail(String email);
}
Enter fullscreen mode Exit fullscreen mode

Service Layer

package com.example.webflux.service;

import org.springframework.stereotype.Service;

import com.example.webflux.model.User;
import com.example.webflux.repository.UserRepository;

import lombok.RequiredArgsConstructor;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;

    public Flux<User> getAllUsers() {
        return userRepository.findAll();
    }

    public Mono<User> getUserById(String id) {
        return userRepository.findById(id)
                .switchIfEmpty(Mono.error(
                        new RuntimeException("User not found with id: " + id)));
    }

    public Mono<User> createUser(User user) {
        return userRepository.findByEmail(user.getEmail())
                // If user exists, throw error
                .flatMap(existingUser -> Mono.<User>error(
                        new RuntimeException("Email already in use")))
                // Otherwise, save the user
                .switchIfEmpty(userRepository.save(user));
    }

    public Mono<User> updateUser(String id, User user) {
        return userRepository.findById(id)
                .switchIfEmpty(Mono.error(
                        new RuntimeException("User not found")))
                .flatMap(existingUser -> {
                    existingUser.setName(user.getName());
                    existingUser.setEmail(user.getEmail());
                    existingUser.setRole(user.getRole());
                    return userRepository.save(existingUser);
                });
    }

    public Mono<Void> deleteUser(String id) {
        return userRepository.findById(id)
                .switchIfEmpty(Mono.error(
                        new RuntimeException("User not found")))
                .flatMap(userRepository::delete);
    }

}
Enter fullscreen mode Exit fullscreen mode

Key patterns here:

  • flatMap: Chain async operations together.
  • switchIfEmpty: Provide fallback for empty results.
  • map: Transform data.
  • Errors are Mono.error(), not exceptions. #### REST Controller
package com.example.webflux.controller;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import com.example.webflux.model.User;
import com.example.webflux.service.UserService;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
    private final UserService userService;

    @GetMapping
    public Flux<User> getAllUsers() {
        return userService.getAllUsers();
    }

    @GetMapping("/{id}")
    public Mono<User> getUserById(@PathVariable String id) {
        return userService.getUserById(id);
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Mono<User> createUser(@Valid @RequestBody User user) {
        return userService.createUser(user);
    }

    @PutMapping("/{id}")
    public Mono<User> updateUser(
            @PathVariable String id,
            @Valid @RequestBody User user) {
        return userService.updateUser(id, user);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public Mono<Void> deleteUser(@PathVariable String id) {
        return userService.deleteUser(id);
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice: The controller looks almost identical to Spring MVC. Return Mono<User> instead of User, and Spring WebFlux handles the subscription and response serialization.

Global Exception Handler

package com.example.webflux.exception;

import java.util.HashMap;
import java.util.Map;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.bind.support.WebExchangeBindException;

import reactor.core.publisher.Mono;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(RuntimeException.class)
    public Mono<ResponseEntity<Map<String, String>>> handleRuntimeException(
            RuntimeException ex) {
        Map<String, String> error = new HashMap<>();
        error.put("error", ex.getMessage());
        return Mono.just(
                ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error));
    }

    @ExceptionHandler(WebExchangeBindException.class)
    public Mono<ResponseEntity<Map<String, String>>> handleValidationException(
            WebExchangeBindException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors()
                .forEach(error -> errors.put(error.getField(), error.getDefaultMessage()));
        return Mono.just(
                ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors));
    }
}
Enter fullscreen mode Exit fullscreen mode

Common Mistakes (And How to Avoid Them)

1. Blocking inside reactive chains

This will destroy performance:

// DON'T DO THIS
public Mono<User> getUser(String id) {
    return userRepository.findById(id).map(user -> {
        Thread.sleep(1000); // BLOCKS THE ENTIRE THREAD POOL
        return user;
    });
}
Enter fullscreen mode Exit fullscreen mode

Do this instead:

// Use reactive delay
public Mono<User> getUser(String id) {
    return userRepository.findById(id)
            .delayElement(Duration.ofSeconds(1));
}
Enter fullscreen mode Exit fullscreen mode

2. Calling .block() (Except in Tests)

// DON'T DO THIS IN PRODUCTION
Mono<User> user = userService.getUser(id);
User actualUser = user.block(); // Defeats the entire purpose
Enter fullscreen mode Exit fullscreen mode

If you need blocking code, you're using the wrong framework.

3. Forgetting to Handle Errors

// BAD - Unhandled errors will crash your request
userRepository.findById(id)
    .map(user -> transform(user));

// GOOD - Explicit error handling
userRepository.findById(id)
    .map(user -> transform(user))
    .onErrorResume(error -> {
        log.error("Error fetching user", error);
        return Mono.empty(); // or a fallback value
    });
Enter fullscreen mode Exit fullscreen mode

4. Subscribing Manually in Controllers

// DON'T DO THIS
@GetMapping("/users")
public void getUsers() {
    userService.getAllUsers()
        .subscribe(user -> System.out.println(user)); // Spring won't wait for this
    // Method returns immediately, subscription happens in background
}

// DO THIS
@GetMapping("/users")
public Flux<User> getUsers() {
    return userService.getAllUsers(); // Spring subscribes automatically
}
Enter fullscreen mode Exit fullscreen mode

Testing

Use WebTestClient for integration tests:

package com.example.webflux;

import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
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.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.reactive.server.WebTestClient;

import com.example.webflux.model.User;
import com.example.webflux.repository.UserRepository;

import reactor.core.publisher.Mono;

@SpringBootTest
@AutoConfigureWebTestClient
public class UserControllerTest {

    @MockBean
    private UserRepository userRepository;

    @Autowired
    private WebTestClient webTestClient;

    @Test
    void testGetAllUsers() {
        webTestClient.get()
                .uri("/api/users")
                .exchange()
                .expectStatus().isOk()
                .expectBodyList(User.class)
                .hasSize(0); // Assuming empty DB
    }

    @Test
    void testCreateAndRetrieveUser() {
        User newUser = new User(null, "Alice", "alice@example.com", "USER");
        User savedUser = new User("123", "Alice", "alice@example.com", "USER");

        Mockito.when(userRepository.findByEmail("alice@example.com"))
                .thenReturn(Mono.empty()); // Email doesn't exist yet

        Mockito.when(userRepository.save(Mockito.any(User.class)))
                .thenReturn(Mono.just(savedUser));

        webTestClient.post()
                .uri("/api/users")
                .bodyValue(newUser)
                .exchange()
                .expectStatus().isCreated()
                .expectBody(User.class)
                .value(user -> {
                    assert user.getName().equals("Alice");
                    assert user.getId() != null;
                });
    }

    @Test
    void testCreateUserWithDuplicateEmail() {
        User newUser = new User(null, "Bob", "bob@example.com", "USER");
        User existingUser = new User("456", "Robert", "bob@example.com", "USER");

        Mockito.when(userRepository.findByEmail("bob@example.com"))
                .thenReturn(Mono.just(existingUser)); // Email already exists

        webTestClient.post()
                .uri("/api/users")
                .bodyValue(newUser)
                .exchange()
                .expectStatus().isBadRequest()
                .expectBody()
                .jsonPath("$.error").exists();
    }

    @Test
    void testGetUserNotFound() {
        Mockito.when(userRepository.findById("nonexistent"))
                .thenReturn(Mono.empty());

        webTestClient.get()
                .uri("/api/users/nonexistent")
                .exchange()
                .expectStatus().isBadRequest()
                .expectBody()
                .jsonPath("$.error").exists();
    }

    @Test
    void testUpdateUser() {
        User existingUser = new User("789", "Charlie", "charlie@example.com", "USER");
        User updatedUser = new User("789", "Charles", "charles@example.com", "ADMIN");

        Mockito.when(userRepository.findById("789"))
                .thenReturn(Mono.just(existingUser));

        Mockito.when(userRepository.save(Mockito.any(User.class)))
                .thenReturn(Mono.just(updatedUser));

        webTestClient.put()
                .uri("/api/users/789")
                .bodyValue(updatedUser)
                .exchange()
                .expectStatus().isOk()
                .expectBody(User.class)
                .value(user -> {
                    assert user.getName().equals("Charles");
                    assert user.getRole().equals("ADMIN");
                });
    }

    @Test
    void testDeleteUser() {
        User userToDelete = new User("999", "Dave", "dave@example.com", "USER");

        Mockito.when(userRepository.findById("999"))
                .thenReturn(Mono.just(userToDelete));

        Mockito.when(userRepository.delete(Mockito.any(User.class)))
                .thenReturn(Mono.empty());

        webTestClient.delete()
                .uri("/api/users/999")
                .exchange()
                .expectStatus().isNoContent();
    }
}
Enter fullscreen mode Exit fullscreen mode

Key testing patterns:

  • Use WebTestClient instead of traditional MockMvc—it's built for reactive streams.
  • Mock repository methods to return Mono<> or Flux<> directly.
  • Test error scenarios explicitly (duplicate emails, not found, validation failures).
  • Use consumeWith() to assert on error response bodies for non-2xx responses.
  • Use @AutoConfigureWebTestClient to automatically inject the test client.

Performance Reality Check

WebFlux shines in specific scenarios. Here's a more detailed breakdown with real-world numbers:

Throughput Comparison

Under sustained load with 100ms I/O latency per request:

  • Spring MVC (200 threads): ~2,000 requests/second, but starts queuing heavily above 200 concurrent users. Average response time degradation: linear with queue depth.
  • Spring WebFlux (12 threads): ~8,000–12,000 requests/second with minimal queuing. Response times remain stable even at 5,000+ concurrent users.

The throughput advantage disappears if your I/O operations are slow (e.g., 500ms database queries). Both frameworks will be equally bottlenecked at that point.

Latency Under High Concurrency

With 5,000 concurrent users making requests with 50ms I/O:

  • Spring MVC: p99 latency ~8–12 seconds (heavy queuing).
  • Spring WebFlux: p99 latency ~150–200ms (handling concurrency smoothly).

This is where WebFlux earns its keep.

CPU-Intensive Work

Both frameworks show similar performance for CPU-bound operations. WebFlux's advantage disappears entirely if you're doing heavy computation in request handlers. Thread count becomes irrelevant.

┌─────────────────────────────────────┬──────────────────┬──────────────────┐
│ Scenario                            │ Spring MVC       │ Spring WebFlux   │
├─────────────────────────────────────┼──────────────────┼──────────────────┤
│ 100 concurrent requests,            │ Efficient        │ Efficient        │
│ 100ms latency per request           │ (~1,000 req/s)   │ (~1,000 req/s)   │
├─────────────────────────────────────┼──────────────────┼──────────────────┤
│ 10,000 concurrent requests,         │ Queuing, p99 lag │ Handles smoothly │
│ 100ms latency                       │ >5s              │ p99 <200ms       │
├─────────────────────────────────────┼──────────────────┼──────────────────┤
│ CPU-intensive work                  │ Fine             │ Still slow       │
│ (no I/O wait)                       │ Same throughput  │ Same throughput  │
├─────────────────────────────────────┼──────────────────┼──────────────────┤
│ Simple CRUD with low traffic        │ Simpler          │ Unnecessary      │
│ (<100 concurrent)                   │ ~same perf       │ complexity       │
└─────────────────────────────────────┴──────────────────┴──────────────────┘
Enter fullscreen mode Exit fullscreen mode

The key metric: If your application is I/O-bound (waiting on databases, APIs, file systems) and you expect high concurrency (1,000+), WebFlux can improve throughput by 3–5x. Otherwise, the added complexity isn't worth it.

When to Migrate Away From WebFlux

If you encounter these problems, it's time to consider going back:

1. Complex reactive chains are hard to debug

Stack traces in reactive code point to operators, not your logic. A single error can involve 10+ levels of indirection through flatMap, map, and switchIfEmpty chains. Debugging becomes archaeological work rather than straightforward step-through.

2. Team productivity drops

Developers unfamiliar with reactive patterns write brittle code. They struggle with understanding when subscriptions happen, why values aren't flowing, and how to compose operators. Code review cycles get longer. Bug fixes create more bugs.

3. You have blocking dependencies

If you're using JDBC, legacy JPA, or third-party libraries that block, you'll resort to .blockingGet() or other blocking workarounds. This defeats the entire purpose of WebFlux and creates thread starvation issues.

4. Performance didn't actually improve

Your bottleneck was always the database query speed, not thread count. You're getting 50ms responses from the database—adding more concurrency doesn't help if your queries are slow. WebFlux becomes overhead.

5. Operational complexity increased

Debugging production issues, monitoring thread pools, understanding backpressure behavior—these add operational burden that may not justify the throughput gains for your use case.

How to migrate back:

Start with your repository layer. Replace reactive repositories with standard JPA:

// OLD: Reactive
public interface UserRepository extends ReactiveMongoRepository<User, String> {
    Mono<User> findById(String id);
}

// NEW: Blocking
public interface UserRepository extends JpaRepository<User, String> {
    User findById(String id);
}
Enter fullscreen mode Exit fullscreen mode

Then update your service layer to return non-reactive types:

// OLD: Reactive
public Mono<User> getUserById(String id) {
    return userRepository.findById(id)
            .switchIfEmpty(Mono.error(new RuntimeException("Not found")));
}

// NEW: Blocking
public User getUserById(String id) {
    return userRepository.findById(id)
            .orElseThrow(() -> new RuntimeException("Not found"));
}
Enter fullscreen mode Exit fullscreen mode

Your controllers automatically work with Spring MVC once you return non-reactive types:

// Works with both Spring MVC and WebFlux, but now uses blocking I/O
@GetMapping("/{id}")
public User getUserById(@PathVariable String id) {
    return userService.getUserById(id);
}
Enter fullscreen mode Exit fullscreen mode

Finally, replace spring-boot-starter-webflux with spring-boot-starter-web in your pom.xml. The migration is straightforward if you're only a few months in. Larger applications will take more effort, but the payoff in team productivity and simplicity might be worth it.

Summary

Use Spring WebFlux for:

  • High-concurrency, I/O-intensive applications (1,000+ concurrent users).
  • Microservices with multiple inter-service calls where parallelism matters.
  • Streaming or real-time data applications.
  • Teams experienced with reactive programming.

Stick with Spring MVC for:

  • Standard business applications with normal traffic (<1,000 concurrent).
  • Teams unfamiliar with reactive programming.
  • Applications with blocking dependencies (JDBC, legacy code).
  • Projects where simplicity and debuggability are more important than theoretical throughput.

The best framework is the one your team can build and operate confidently. Choose based on actual requirements, not because "reactive is trendy."

Top comments (0)