In this article, we try to prove that Reactor—and the entire reactive programming paradigm—is not dead. Quite the opposite: it has gained a second life. This happened with the arrival of JDK 21, when virtual threads significantly changed the landscape of concurrency in Java.
Table of Contents
- Business Problem
- The Simplest Solution – Spring Boot
- Performance Testing
- The Problem with Thread Blocking
- Spring Boot in Reactive Version
- Does the Reactive Approach Make Sense?
- Java 21 and Virtual Threads
- Virtual Threads in Action
- Classic Concurrency
- Structured Concurrency
- Reactor – A Second Birth
- The Last, Missing Piece of the Puzzle
- Summary
Business Problem
We need a sensible business example. This will show how reactive programming and virtual threads intertwine. We will use a simple model of airline ticket sales. It exposes a classic REST API. Underneath, the API communicates with the airline’s server to prepare the ticket.
Let’s complicate things a bit. The purchase process requires separate calls for “outbound” and “return” flights. The diagram below illustrates the whole process:

Let’s also assume a single query to the external server takes about 3 seconds. This is not necessarily an exaggeration under heavy network load. Such a delay will become crucial later in the article.
The Simplest Solution – Spring Boot
Implementing such an API in Spring Boot is extremely simple. In practice, it boils down to preparing two classes. We show these below:
@RestController
@RequiredArgsConstructor
public class BookingController {
private final BookingService bookingService;
@PostMapping("/booking")
public String book(@RequestParam(value = "destination") String destination) {
return bookingService.book(destination);
}
}
@Service
@Slf4j
public class BookingService {
enum TripType {
THERE,
BACK
}
public String book(String destination) {
return String.join("\n",
book(destination, TripType.THERE),
book(destination, TripType.BACK)
);
}
@SneakyThrows
private String book(String destination, TripType tripType) {
log.info("[{} {}] Booking start", destination, tripType);
Thread.sleep(3000);
log.info("[{} {}] Booking end", destination, tripType);
return String.format("Booked %s travel to: %s", tripType, destination);
}
}
As you can see, the code is clean and understandable. Even a beginner programmer should have no problem grasping it.
Does this code work? Functionally, absolutely. The example call in Postman below proves it:
POST http://localhost:8080/booking?destination=Katowice
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 66
Date: Mon, 26 Jan 2026 12:17:51 GMT
Booked THERE travel to: Katowice
Booked BACK travel to: Katowice
Response code: 200; Time: 6192ms (6 s 192 ms); Content length: 66 bytes (66 B)
It’s worth noting the response time is about 6 seconds. This is fully expected. We need to independently purchase two tickets—one way and return. Each of those calls takes roughly 3 seconds.
Performance Testing
The fact the code works for a single request offers no proof it will cope under heavy server load. To check application behavior in such a situation, we cannot conduct tests manually. We would need to hire a thousand employees to click the endpoint, which makes little business sense.
For this reason, we need tools created specifically for performance testing. In the Java world, we most often reach for two solutions: Gatling and JMeter. I chose Gatling, because I prefer writing test scenarios in code form. Gatling also allows the use of Java, which is exactly what we need.
A sample test might look like this: we execute requests to our endpoint. We try to generate a load of 1000 users over 10 seconds:
public class BookingSimulation extends Simulation {
AtomicInteger counter = new AtomicInteger(0);
ChainBuilder bookTravel =
exec(session -> session.set("bookingId", counter.getAndIncrement()))
.exec(
http("Post booking form")
.post("/booking")
.queryParam("destination", "Katowice-#{bookingId}")
.check(status().is(200))
);
HttpProtocolBuilder httpProtocol =
http.baseUrl("http://localhost:8080");
ScenarioBuilder travelBookingScenario = scenario("Book").exec(bookTravel);
{
setUp(
travelBookingScenario.injectOpen(rampUsers(1000).during(10))
).protocols(httpProtocol);
}
}
After running the Gatling script—with the Maven command (mvn gatling:test) —we get the chart below.
As you can see, we encounter a few unpleasant surprises. Firstly, the average request processing time is not 6 seconds, as you might expect. Only about one-fifth of requests fit within that time. Each subsequent 20% group takes longer. The last one exceeds 20 seconds, which is absolutely unacceptable:

Let’s also look at the second part of the chart. The server simply “stalls” for a certain period. It is unable to handle any new requests. This is a clear signal. The classic blocking approach begins to choke under heavier load:


The Problem with Thread Blocking
Why did the test results prove so poor? The reason is fundamentally trivial. Just look at the diagram below:

It clearly shows that for most of the time, the threads handling our requests do nothing. They wait for a response from the external server, wasting resources. Moreover, this diagram is quite optimistic. In real-world measurements, threads perform actual work for only about 1–2% of their lifespan.
This also explains the shape of the previous chart. The default Tomcat thread pool (Spring Boot automatically starts it) is about 200 threads. Our test generated traffic from 1000 concurrent users. This means only 1/5 of requests can be processed immediately. The rest must wait in a queue. This is exactly what the subsequent batches of response times visible in the diagram correspond to.
Since things are so bad, why do people still commonly use the blocking model? Despite its performance limitations, it has one very important advantage. It provides a kind of “Shakespearean unity of time, place, and action.” One thread handles one request. One thread is only busy with one request at a time. Thanks to this, we can effortlessly use various mechanisms based on ThreadLocal, such as MDC and SecurityContext. Programmatically, this approach is extremely simple, intuitive, and most importantly, most frameworks build upon it by default.
Spring Boot in Reactive Version
The main problem we encountered was the wasting of a single thread’s time. It is worth asking: what if we utilized threads to the maximum? They wouldn’t waste time waiting idly.
However, such an approach requires a different philosophy. We must enter the world of the so-called event loop. We perform an operation until we encounter a blocking fragment. When this happens, we pass the task back to the event loop. We wait for the appropriate callback to inform us we can continue. In the meantime, the thread, instead of waiting, can handle subsequent requests. The diagram below illustrates this concept:

This non-blocking, event-driven approach is called reactive programming. It often goes hand-in-hand with stream processing and the functional paradigm. This forms the foundation of libraries like RxJava or Reactor.
Spring Boot provides a reactive technology stack (with Reactor used under the hood). We will use it to shift our example into the world of reactivity. So let’s see what the code looks like after this transformation:
@RestController
@RequiredArgsConstructor
public class RxBookingController {
private final RxBookingService bookingService;
@PostMapping("/booking")
public Mono<String> book(@RequestParam(value = "destination") String destination) {
return bookingService.book(destination);
}
}
@Service
@Slf4j
public class RxBookingService {
enum TripType {
THERE,
BACK
}
public Mono<String> book(String destination) {
return Mono.zip(
book(destination, TripType.THERE),
book(destination, TripType.BACK),
(there, back) -> String.join("\n", there, back)
);
}
private Mono<String> book(String destination, TripType tripType) {
return Mono.fromCallable(() -> destination)
.doOnNext(s -> log.info("[{} {}] Booking start", destination, tripType))
.delayElement(Duration.ofSeconds(3))
.map(d -> String.format("Booked %s travel to: %s", tripType, d))
.doOnNext(s -> log.info("[{} {}] Booking end", destination, tripType));
}
}
The controller code changes only cosmetically. The Mono type (from Reactor library) in the returned result is the only clear signal we are dealing with a reactive stack. Mono represents a “promise” to deliver a value in the future, not a concrete value itself.
Significantly larger differences appear in the service layer. In the reactive and streaming approach, you must write the code as operations that build a stream. Individual processing stages are connected. Everything must fit within a “chain of reactions.” Reactive streams are inherently lazy. Nothing executes until someone subscribes to the stream. This approach offers powerful possibilities. However, it requires thinking about the program in a completely different way than in the blocking model.
Does the Reactive Approach Make Sense?
To answer the question of whether the reactive approach actually solves our problems, let’s run the test again using Gatling. We will then analyze the results:



As you can see, changing to the reactive model ensures all requests are handled in about 3 seconds. (Why not 6? We will discuss this later when we return to the Mono.zip operator). More importantly, subsequent charts show the system does not experience any stalls. Requests are processed continuously. The difference compared to the blocking approach is clearly positive.
Additionally, we can look at the number of threads used during the test. We will notice there were only 32 of them. This is a huge leap in quality compared to the previous 200. We use just over 10% of the original number of threads. We achieve much better performance.
However, the reactive approach is not without its drawbacks. The code becomes less readable. Understanding and maintenance require greater experience, usually at least at the regular, if not senior developer, level. Moreover, the learning curve is quite steep. The reactive philosophy forces a completely different way of thinking about data flow.
Another significant problem is the “viral” nature of reactive types. Mono and Flux tend to permeate the entire public API. This can be undesirable from a business perspective.
In summary: the reactive approach can be a very good solution if we need high performance and handling a large number of simultaneous requests. This comes at the cost of greater code complexity and reduced readability. However until JDK 21’s arrival, we had almost none alternatives here.
Java 21 and Virtual Threads
The situation changed dramatically with the arrival of JDK 21. This included one of the most anticipated features: virtual threads. What is this change based on? Essentially, something surprisingly simple: if a thread encounters a blocking operation, the virtual machine can suspend it. Other tasks can execute in the meantime.
We now deal with two types of threads. Before, there was only one. They divided the Thread class into two subclasses: classic platform threads (system VM threads) and new virtual threads.
The assumption is the JVM prepares a small pool of so-called carrier threads. These run the virtual thread code. When a carrier thread is assigned to a virtual thread, it begins executing code. If it hits a blocking operation, the JVM “parks” it. It saves its state in memory. The carrier thread immediately receives another ready-to-run virtual thread. When the awaited operation finishes, the virtual thread returns to further work, perhaps on a different carrier thread. Whole picture looks as follows:

Superficially, this looks very similar to the reactive model. However, the difference is fundamental. Everything happens completely transparently to the programmer. We do not have to manually manage non-blocking calls, callbacks, or reactive streams. The JVM takes care of it. For us, the code remains sequential, readable, and completely “non-reactive.”
For such an approach to work, deeply reorganizing the implementation of the Thread class was necessary. The entire I/O stack—network, file, etc.—directly in the virtual machine also needed reorganization. This work happened as part of the JEP introducing virtual threads.
Virtual Threads in Action
Virtual threads bring many advantages. The natural question arises: is their use in our application difficult? The answer is: absolutely not. Setting just one property in the application.properties file is enough in this case:
spring.threads.virtual.enabled=true
Let’s look at how the use of virtual threads affects system performance. We will use exactly the same code as in the original, non-reactive Spring Boot example. We will only add the mentioned setting. Then we will run the tests in Gatling again and compare the results:

It turns out that after enabling virtual threads, the application handles all requests without any problem. It achieves performance comparable to the reactive approach. However, there is one significant difference. The service time for a single request is 6 seconds. In the reactive version, it was 3 seconds.
Where does this discrepancy come from? Virtual threads were supposed to eliminate the main differences between blocking and reactive systems. This is because simply enabling virtual threads does not automatically cause parallel execution of operations. In the reactive model, we got this “for free” thanks to the Mono.zip operator. In the case of classic, sequential code, we still execute two queries one after the other. Hence the full 6 seconds.
So we see we still face the challenge of parallelizing operations. This is the classic problem of concurrency.
Classic Concurrency
When it comes to concurrency in Java, we have several approaches. The first, most classic, relies on using ExecutorService. We run the ticket purchase processes in parallel threads. This is shown in the example below:
@Service
@Slf4j
public class BookingService {
enum TripType {
THERE,
BACK
}
/*
Default ThreadFactory used by StructuredTaskScope does not set names for created threads
which makes threads logging unusable
*/
private final ThreadFactory threadFactory = Thread.ofVirtual().name("booking-", 0).factory();
private final ExecutorService executorService = Executors.newCachedThreadPool(threadFactory);
@SneakyThrows
public String book(String destination) {
Future<String> there = executorService.submit(() -> book(destination, TripType.THERE));
Future<String> back = executorService.submit(() -> book(destination, TripType.BACK));
return String.join("\n",
there.get(),
back.get()
);
}
@SneakyThrows
private String book(String destination, TripType tripType) {
log.info("[{} {}] Booking start", destination, tripType);
Thread.sleep(3000);
log.info("[{} {}] Booking end", destination, tripType);
return String.format("Booked %s travel to: %s", tripType, destination);
}
}
The code itself is not complicated. Theoretically, it differs little from what we had at the beginning of the project creation. Moreover, its performance—as Gatling can easily verify—would be practically identical to the reactive solution. Requests would be handled smoothly. The average response time would be about 3 seconds. Sounds like a big success, right?
Unfortunately, that’s only half the truth. Classic concurrency in Java is associated with several significant pitfalls:
- thread leak — if the parent thread finishes with an exception, child threads may continue to run, not knowing their results are no longer needed,
- unnecessary waiting — sometimes we wait for an operation to complete, even though other parallel tasks have already failed,
- problem with grouping results — the final result requires manually collecting the results of all tasks. This affects the execution time. This cannot be done as elegantly as with the
Mono.zipoperator in the reactive approach.
However, all these problems can be solved. This is thanks to the approach known as structured concurrency. We will talk about this in a moment.
Structured Concurrency
Structured concurrency relies on one simple paradigm:
The lifetime of child tasks is strictly linked to the lifetime of the parent task and cannot exceed it.

This means that the moment the parent task finishes, we have a full guarantee all child tasks have also finished. In practice, this eliminates the possibility of thread leakage. Additionally, such a paradigm forces the existence of a policy. This policy specifies what the result of the parent task execution should be when one of the child tasks ends with an error. Thus, the other two problems of classic concurrency we talked about earlier also disappear.
JDK 21 introduced structured concurrency as a preview feature. It was then significantly improved (in a backward-incompatible way, unfortunately!) in JDK 25. In our example, we will use JDK 25. We will show how to easily use the classes made available there to achieve the intended effect:
@Service
@Slf4j
public class BookingService {
enum TripType {
THERE,
BACK
}
/*
Default ThreadFactory used by StructuredTaskScope does not set names for created threads
which makes threads logging unusable
*/
private final ThreadFactory threadFactory = Thread.ofVirtual().name("booking-", 0).factory();
@SneakyThrows
public String book(String destination) {
try (var scope = new StructuredTaskScope.ShutdownOnFailure("Booking", threadFactory)) {
Supplier<String> there = scope.fork(() -> book(destination, TripType.THERE));
Supplier<String> back = scope.fork(() -> book(destination, TripType.BACK));
scope.join().throwIfFailed();
return String.join("\n",there.get(), back.get());
}
}
@SneakyThrows
private String book(String destination, TripType tripType) {
log.info("[{} {}] Booking start", destination, tripType);
Thread.sleep(3000);
log.info("[{} {}] Booking end", destination, tripType);
return String.format("Booked %s travel to: %s", tripType, destination);
}
}
As you can see, the presented code is as simple as in the case of traditional concurrency. Yet, it removes all the problems the classic approach generated. One might think we have reached the “holy grail” we were looking for. And indeed, that might be the case… if it weren’t for one detail. This detail is clearly visible when opening the pom.xml file:
<build>
<plugins>
...
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<compilerArgs>--enable-preview</compilerArgs>
</configuration>
</plugin>
...
</plugins>
</build>
Unfortunately, structured concurrency is still treated as a preview functionality. It will only be available in a fully production version when it hits an LTS release. This will most likely not happen before the premiere of JDK 29. This means that despite its huge potential, this functionality is not yet suitable for use in production code. As a result, this forces us to look for another alternative.
And at this point, we naturally return to our Reactor.
Reactor – A Second Birth
Until virtual threads arrived, blocking Reactor’s worker threads (which make up the event-loop) was treated as a serious error. This was because the number of such threads was small. Each blocking operation caused a significant drop in the performance of the entire framework. However, with the introduction of virtual threads, this limitation lost its significance. Virtual threads can be blocked with impunity. This means we can afford to write part of the logic based on Reactor operators. We can then call the block() method. This transforms the reactive flow into blocking code. It gets rid of Mono / Flux objects in our API.
The only question that arises is: why does this approach even make sense? The answer lies in the rich set of Reactor operators. We have a situation where we want to perform two parallel operations and then combine their results. This is a difficult task in traditional concurrency. Structured concurrency facilitates this process. However, it is still not a production-ready solution. Meanwhile, the Mono.zip operator gives us this functionality “for free.”
Let’s notice one more thing: to use Reactor, we do not have to drag the entire reactive stack. This includes the non-blocking HTTP server/client and all additional dependencies. We can include only Reactor Core itself in the application. This is very lightweight and does not introduce unnecessary elements. This is completely sufficient to use operators such as Mono.zip. Let’s see what the dependency in our pom.xml would look like:
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>
<version>3.6.10</version>
</dependency>
Of course, let’s also remember that for the whole thing to work correctly, we must enforce the use of virtual threads on the server side. Fortunately, this is still trivially simple. It boils down to setting the same property in application.yml that we discussed earlier.
Finally the code looks like as follows:
@Service
@Slf4j
public class BookingService {
enum TripType {
THERE,
BACK
}
public String book(String destination) {
return Mono.zip(
book(destination, TripType.THERE),
book(destination, TripType.BACK),
(there, back) -> String.join("\n", there, back)
).block();
}
private Mono<String> book(String destination, TripType tripType) {
return Mono.fromCallable(() -> destination)
.doOnNext(s -> log.info("[{} {}] Booking start", destination, tripType))
.delayElement(Duration.ofSeconds(3))
.map(d -> String.format("Booked %s travel to: %s", tripType, d))
.doOnNext(s -> log.info("[{} {}] Booking end", destination, tripType));
}
}
If we were to test the solution for performance now, it would turn out everything works just as well as in a fully reactive application. This is true both in terms of request handling and response time, which again is about 3 seconds. So we are almost at the end of our journey.
We still have one more issue. The code used to communicate with the external airline server in our example is already non-blocking. However, in most real systems, it will not be. We will therefore have to “wrap” the existing blocking API in an appropriate reactive abstraction. Fortunately, with the use of virtual threads, this is not a major problem.
The Last, Missing Piece of the Puzzle
To hook up our existing, blocking solution, we only need to use Reactor’s subscribeOn operator. This forces the execution of blocking code in virtual threads. By default, Reactor cannot delegate such tasks to virtual threads on its own. Preparing a dedicated ThreadFactory is therefore necessary. We can afford a very large “reserve” here. Virtual threads can easily handle even a million active instances or a million elements in the queue. This would be completely unattainable in the case of traditional platform threads.
After preparing the appropriate ThreadFactory, it is enough to build a Reactor scheduler based on it. We then use it in the subscribeOn operator. We hook up our existing blocking API there. And that’s pretty much it. So let’s see what the final result of our work looks like:
@Service
@Slf4j
public class BookingService {
enum TripType {
THERE,
BACK
}
/*
Default boundedElastic() scheduler behaves like a hybrid.
It creates virtual threads but is capped
like they would have been platform ones (10 * number of cores)
It is possible to change these settings via system properties,
however I decided just to create my own scheduler
*/
private final ThreadFactory virtualThreadFactory = Thread.ofVirtual()
.name("vt-factory-", 0)
.factory();
private final Scheduler virtualScheduler = Schedulers
.newBoundedElastic(1_000_000, 1_000_000, virtualThreadFactory, 60);
private final Scheduler scheduler = virtualScheduler;
public String book(String destination) {
return Mono.zip(
book(destination, TripType.THERE).subscribeOn(scheduler),
book(destination, TripType.BACK).subscribeOn(scheduler),
(there, back) -> String.join("\n", there, back)
).block();
}
private Mono<String> book(String destination, TripType tripType) {
return Mono.fromCallable(() -> bookInternal(destination, tripType))
.doOnSubscribe(s -> log.info("[{} {}] Booking start", destination, tripType))
.doOnNext(s -> log.info("[{} {}] Booking end", destination, tripType));
}
@SneakyThrows
private String bookInternal(String destination, TripType tripType) {
Thread.sleep(3000);
return String.format("Booked %s travel to: %s", tripType, destination);
}
}
If we run the performance tests again, it turns out the whole thing works exactly as we expected. The total execution time of the operation still averages about 3 seconds. The system maintains full performance with a small number of platform threads. Virtual threads take over the entire burden associated with blocking communication. This relieves the event-loop and ensures high throughput.
The role Reactor plays in the whole puzzle is also very clearly visible. It becomes an intermediate layer. On one hand, it can handle blocking code (e.g., communication with an external server). On the other hand, it allows the API to be exposed in its previous, completely non-reactive form. This is done without cluttering it with types such as Mono or Flux. Thanks to this, we gain the best features of both worlds. This includes the simplicity of classic imperative programming and the strong possibilities for parallelization and combining results offered by Reactor operators.
Summary
The rumors of the “death” of reactive environments have proven to be greatly exaggerated. In practice, they can still find a very good place in the new ecosystem dominated by virtual threads. Reactor today provides us with what will only be available in the production version of structured concurrency. This includes convenient combining of results, stream composition, and a rich set of operators. These perfectly complement the capabilities of virtual threads.
As a result, we get an environment in which we can write code in a classic, imperative style. At the same time, we use the powerful parallelization mechanisms provided by Reactor. This is done without the need for a full migration to the reactive stack. It also avoids “infecting” the API with reactive types.
You can find the entire code presented in this material in the repository: https://github.com/chrosciu/travel-agency. I encourage you to clone it and experiment independently. It’s a great way to experience the differences between individual approaches firsthand. It also helps to better understand how virtual threads work in practice.

This article is part of the JAVAPRO special magazine issue:
Java in the Age of AI
Explore how AI is transforming the way we build, secure, and operate software with Java.
From AI agents and new architectural patterns to security, data, and team dynamics—this edition brings together real-world insights for building intelligent, production-ready systems.
Discover the edition →