← all posts

Reading Spring Reactive

I could read Mono and Flux, but I couldn't say what they actually bought anyone. So I sat down and figured out what "reactive" really changes.

A problem

I’d been writing Spring MVC for years. Controllers, services, repositories, the usual. Then I started seeing code like this in other projects:

public Mono<OrderView> getOrder(String id) {
    return orderRepository.findById(id)
        .flatMap(order -> customerClient.get(order.customerId())
            .map(customer -> OrderView.of(order, customer)))
        .switchIfEmpty(Mono.error(new NotFoundException(id)));
}

I could read it. I could even write it, more or less. But if you’d asked me “why is this better than a normal method that returns an OrderView?”, I’d have said something about “non-blocking” and “scalable” and hoped you wouldn’t ask a follow-up.

So I sat down to understand it properly. These are my notes, written the way I wish someone had explained it to me.

How a normal Spring MVC app handles requests

First, I had to understand what I’d been using all along.

Spring MVC runs on a server like Tomcat, which has a thread pool, 200 threads by default. Each incoming request gets one thread, and that thread stays with the request until the response is sent. That’s thread-per-request.

public OrderView getOrder(String id) {
    Order order = orderRepository.findById(id);          // thread waits for the DB
    Customer customer = customerClient.get(order.customerId()); // thread waits for HTTP
    return OrderView.of(order, customer);
}

Simple, readable, easy to debug. But look at what the thread actually does: it sends a query and then waits. Then it sends an HTTP call and waits again. Most of a typical request’s life is spent waiting on I/O, and during all that time the thread is blocked, doing nothing, but still taken.

So if each request spends 200 ms waiting on a slow downstream service, 200 threads can handle about 1,000 requests per second. Request 201 waits in a queue until a thread frees up. You can add more threads, but each one costs memory (around 1 MB of stack) and context switching.

The threads aren’t busy. They’re waiting. That’s the waste reactive tries to remove.

The reactive idea

Spring WebFlux runs on Netty, with a small, fixed number of event-loop threads, roughly one per CPU core. The rule is that these threads never wait.

Instead of “send query, wait for result”, it’s “send query, and here’s what to do when the result arrives”. The thread is immediately free to handle something else. When the database answers, an event loop thread picks up the result and runs the next step.

thread-per-request:   [req A: query.....wait.....wait.....done]
                      [req B: query.....wait.....wait.....done]

event loop:           [A: send query][B: send query][A: result → call API][B: result → ...]

A handful of threads can juggle thousands of in-flight requests, because none of them are sitting around waiting.

Mono and Flux

To describe “what to do when the result arrives”, Reactor gives you two types:

  • Mono<T>: something that will produce 0 or 1 value later. Like a single DB row.
  • Flux<T>: something that will produce 0 to N values over time. Like a list of rows, or a stream of events.

You don’t get the value. You get a description of how the value will be produced, and you chain steps onto it:

orderRepository.findById(id)              // Mono<Order>
    .flatMap(order -> customerClient.get(order.customerId())  // Mono<Customer>
        .map(customer -> OrderView.of(order, customer)))      // Mono<OrderView>
  • map transforms a value: Order → OrderView.
  • flatMap is for when the next step is itself asynchronous and returns another Mono, like calling another service.

Nothing happens until you subscribe

This was the part that tripped me up the most:

Mono<Order> order = orderRepository.findById(id);
// no query has been sent yet!

That line doesn’t run a query. It builds a pipeline. Nothing executes until something subscribes to it. In WebFlux, the framework subscribes when it writes the response. In a test, you subscribe yourself, for example with StepVerifier or .block().

Once that clicked, a lot of “why didn’t my code run?” bugs made sense.

Backpressure

With Flux, there’s one more idea: backpressure. A consumer can tell the producer “I can handle 10 items right now” (request(10)), so a fast producer can’t drown a slow consumer.

Think of reading a million rows from a database and writing them to a slow HTTP client. Without backpressure, you’d load them all into memory. With it, rows flow at the speed the client can take them.

The one rule you can’t break

Never block an event-loop thread.

There are only a few of them. If one of them calls something blocking, like JDBC, Thread.sleep, a blocking HTTP client, or .block(), then every request sharing that thread stops too. One careless call can freeze a big part of the application.

// ❌ blocks the event loop
.map(id -> jdbcTemplate.queryForObject(...))

// ✅ if you really must call blocking code, move it to a thread pool meant for it
Mono.fromCallable(() -> jdbcTemplate.queryForObject(...))
    .subscribeOn(Schedulers.boundedElastic())

This also means the whole stack needs to be non-blocking to get the benefit: R2DBC instead of JDBC for the database, WebClient instead of RestTemplate for HTTP. A reactive controller on top of a blocking driver gives you all the complexity and none of the gains.

A tool called BlockHound can detect blocking calls on event-loop threads during tests. I wish I’d known about it earlier.

The price

Reactive isn’t free:

  • Stack traces get hard to read. An exception’s stack shows Reactor internals, not your call chain. Hooks.onOperatorDebug() or the checkpoint() operator help, at some cost.
  • Debugging is harder. You can’t just step through line by line, because the “lines” run at different times on different threads.
  • Thread-local things break. Anything relying on ThreadLocal, like MDC logging or some security contexts, needs special handling, because one request jumps between threads.
  • The learning curve is real. The team has to learn a new way of thinking, not just new syntax.

When it’s worth it, and virtual threads

From everything I read, reactive pays off when you have lots of concurrent, I/O-heavy work: API gateways, streaming, services that fan out to many downstream calls. For a typical CRUD app on a relational database, it mostly adds complexity.

And then Java 21 brought virtual threads, which changed the picture a lot. A virtual thread is cheap. When it blocks on I/O, the JVM parks it and frees the underlying OS thread. So you can write plain, blocking, thread-per-request code, and still handle huge numbers of concurrent requests. In Spring Boot it’s one setting:

spring.threads.virtual.enabled=true

That solves the “threads waiting” problem without changing how you write code. Reactive still has things virtual threads don’t, like backpressure and real streaming, but for many services the main reason to go reactive has become much weaker.

What I took away

“Reactive” isn’t about being faster. A single request isn’t quicker. It’s about not wasting threads while waiting, so the same hardware can handle more concurrent work.

Now when I see Mono and Flux, I can finally say what they’re buying, and also ask the more useful question: does this service actually need it?

References

Discussion

Enjoy!