Skip to content
CalliCoder

Reactive REST APIs with Spring WebFlux and MongoDB

Published Updated Spring Boot 12 min read

A reactive stack end to end: annotated and functional routing, a reactive repository, streaming with text/event-stream, and the one blocking call that removes every benefit.

WebFlux is not a faster MVC. It is a different concurrency model: a small fixed number of event-loop threads, each handling many requests, none of them ever blocking. That buys high concurrency at low thread cost, and it only works if nothing in the request path blocks. One JDBC call, one .block(), one synchronous file read, and the model is gone while the code still compiles and the tests still pass.

So the honest first question is not how to use it but whether to. If your data access is JDBC, MVC with virtual threads (Java 21) gets most of the concurrency benefit for none of the rewrite. WebFlux earns its place when the whole path is reactive: a reactive database driver, reactive HTTP clients, streaming responses.

Written against Spring Boot 3.2, Java 17 and MongoDB 7.

Dependencies

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-mongodb-reactive</artifactId>
</dependency>

data-mongodb-reactive, not data-mongodb. The blocking starter’s repositories return List and Optional, and calling one from a WebFlux handler blocks an event-loop thread.

Do not add spring-boot-starter-web. With both present Spring Boot chooses MVC and starts Tomcat, so your reactive handlers run on a servlet container. It works, and you have none of the benefits. The startup log tells you which server booted; read it once.

spring.data.mongodb.uri=mongodb://127.0.0.1:27017/notes

The document

@Document(collection = "notes")
public record Note(
        @Id String id,
        String title,
        String content,
        boolean published,
        Instant createdAt) {

    public static Note create(String title, String content) {
        return new Note(null, title, content, false, Instant.now());
    }
}

A record works as a MongoDB document. There is no proxying or lazy loading to interfere with immutability, which is a genuine advantage of the document model here.

The repository

public interface NoteRepository extends ReactiveMongoRepository<Note, String> {

    Flux<Note> findByPublishedTrue();

    Flux<Note> findByTitleContainingIgnoreCase(String fragment);

    @Tailable
    @Query("{ published: true }")
    Flux<Note> streamPublished();
}

Every method returns Mono or Flux, Mono<Note> for at most one, Flux<Note> for many. Query derivation from method names works exactly as in the blocking version.

@Tailable needs a capped collection and gives an infinite Flux that emits as documents are inserted. That is the reactive stack doing something MVC structurally cannot.

Annotated controllers

The familiar programming model, with reactive return types:

@RestController
@RequestMapping("/api/notes")
public class NoteController {

    private final NoteRepository notes;

    public NoteController(NoteRepository notes) {
        this.notes = notes;
    }

    @GetMapping
    public Flux<Note> list() {
        return notes.findAll();
    }

    @GetMapping("/{id}")
    public Mono<ResponseEntity<Note>> byId(@PathVariable String id) {
        return notes.findById(id)
                .map(ResponseEntity::ok)
                .defaultIfEmpty(ResponseEntity.notFound().build());
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Mono<Note> create(@Valid @RequestBody Mono<Note> body) {
        return body.map(n -> Note.create(n.title(), n.content()))
                   .flatMap(notes::save);
    }

    @DeleteMapping("/{id}")
    public Mono<ResponseEntity<Void>> delete(@PathVariable String id) {
        return notes.findById(id)
                .flatMap(existing -> notes.delete(existing)
                        .then(Mono.just(ResponseEntity.noContent().<Void>build())))
                .defaultIfEmpty(ResponseEntity.notFound().build());
    }

    @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<Note> stream() {
        return notes.streamPublished();
    }
}

defaultIfEmpty is how “not found” is expressed: an empty Mono is not an error. It is the absence of a value, so you supply the 404 rather than throwing for it.

produces = TEXT_EVENT_STREAM_VALUE changes the behaviour of the response entirely. Without it, a Flux is collected and serialised as one JSON array; with it, each element is flushed as a server-sent event as it arrives. That is the streaming case, and it is one annotation attribute.

Functional routing

The same endpoints without annotations:

@Configuration
public class NoteRoutes {

    @Bean
    RouterFunction<ServerResponse> routes(NoteHandler handler) {
        return RouterFunctions.route()
                .GET("/fn/notes", handler::list)
                .GET("/fn/notes/{id}", handler::byId)
                .POST("/fn/notes", handler::create)
                .build();
    }
}

@Component
class NoteHandler {

    private final NoteRepository notes;

    NoteHandler(NoteRepository notes) {
        this.notes = notes;
    }

    Mono<ServerResponse> list(ServerRequest request) {
        return ServerResponse.ok()
                .contentType(MediaType.APPLICATION_JSON)
                .body(notes.findAll(), Note.class);
    }

    Mono<ServerResponse> byId(ServerRequest request) {
        return notes.findById(request.pathVariable("id"))
                .flatMap(note -> ServerResponse.ok().bodyValue(note))
                .switchIfEmpty(ServerResponse.notFound().build());
    }

    Mono<ServerResponse> create(ServerRequest request) {
        return request.bodyToMono(Note.class)
                .map(n -> Note.create(n.title(), n.content()))
                .flatMap(notes::save)
                .flatMap(saved -> ServerResponse
                        .created(URI.create("/fn/notes/" + saved.id()))
                        .bodyValue(saved));
    }
}

Routing becomes explicit code rather than annotation scanning, which makes it testable in isolation and composable. Both models run side by side in one application; pick per feature rather than per project.

The one rule

Nothing in the chain may block. These all break the model:

// blocks an event-loop thread
Note n = notes.findById(id).block();

// JDBC in a reactive handler
jdbcTemplate.query(...);

// synchronous I/O
Files.readAllBytes(path);

// RestTemplate
restTemplate.getForObject(url, String.class);

There are only a few event-loop threads, roughly one per core. Blocking one stops every request it was multiplexing, so a single blocking call under load produces latency that looks like a capacity problem and is not.

When you must call something blocking, move it to a bounded elastic scheduler:

Mono.fromCallable(() -> legacyBlockingCall(id))
    .subscribeOn(Schedulers.boundedElastic());

That is a bridge, not a strategy. You have reintroduced a thread pool and its sizing. Use it for one awkward dependency, not as the shape of the application.

Add BlockHound in tests and blocking calls become failures rather than mysteries:

@BeforeAll
static void installBlockHound() {
    BlockHound.install();
}

It instruments the JVM to throw when a blocking method is called on a non-blocking thread, which turns a load-testing discovery into a unit-test failure.

Testing

WebTestClient covers both routing models:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class NoteApiTest {

    @Autowired WebTestClient client;
    @Autowired NoteRepository notes;

    @BeforeEach
    void reset() {
        notes.deleteAll().block();      // fine in a test: no event loop involved
    }

    @Test
    void createsANote() {
        client.post().uri("/api/notes")
                .bodyValue(Note.create("Shopping", "milk"))
                .exchange()
                .expectStatus().isCreated()
                .expectBody()
                .jsonPath("$.id").isNotEmpty()
                .jsonPath("$.title").isEqualTo("Shopping");
    }

    @Test
    void returns404ForMissing() {
        client.get().uri("/api/notes/{id}", "000000000000000000000000")
                .exchange()
                .expectStatus().isNotFound();
    }
}

For the reactive types themselves, StepVerifier asserts on the sequence rather than the value:

@Test
void publishedOnly() {
    StepVerifier.create(notes.findByPublishedTrue())
            .expectNextMatches(Note::published)
            .expectComplete()
            .verify(Duration.ofSeconds(5));
}

Always give verify a timeout. Without one, a Flux that never completes hangs the test run rather than failing it.

Frequently asked questions

Is WebFlux faster than Spring MVC?

Not per request. It sustains far more concurrent connections per thread, which is a throughput and resource story, not a latency one. With Java 21 virtual threads, MVC covers much of the same ground without a rewrite.

Can I use JPA with WebFlux?

Not without blocking, JDBC is a blocking API. Use a reactive driver (MongoDB reactive, R2DBC) or stay on MVC.

Why did Tomcat start instead of Netty?

spring-boot-starter-web is on the classpath. With both starters present Spring Boot chooses MVC. Remove the servlet starter.

How do I return a 404 from a Mono?

An empty Mono is absence, not error. Use defaultIfEmpty with a ResponseEntity, or switchIfEmpty with a ServerResponse.

What is the difference between Mono and Flux?

Mono emits at most one element, Flux zero to many. Repository methods returning a single document use Mono; collections use Flux.

How do I stream instead of returning an array?

Set produces = MediaType.TEXT_EVENT_STREAM_VALUE. Without it the Flux is collected into one JSON array before anything is sent.

Is it ever acceptable to call block()?

In tests and in main, yes. In a request-handling path, no: it stalls an event-loop thread and every request sharing it.

How do I call a blocking library from a reactive chain?

Wrap it in Mono.fromCallable and subscribeOn(Schedulers.boundedElastic()). Treat it as a bridge for one dependency, not an architecture.

How do I catch blocking calls before production?

Install BlockHound in your test setup. It throws when a blocking method runs on a non-blocking thread.

Annotated controllers or functional routing?

Both are fully supported and can coexist. Annotations are familiar; functional routing makes the route table explicit code. Choose per feature.

Where should I go next?

WebClient and WebTestClient is the client side of this stack, and the Spring Boot guides cover the blocking equivalent.