Reactive vs Virtual Thread Patterns: When to Mix Project Reactor and Structured Concurrency
As Java evolves to meet modern scalability demands, developers today have more concurrency options than ever before. Two powerful paradigms stand out:
- Reactive programming with Project Reactor
- Structured concurrency using virtual threads (introduced in Java 21)
Both aim to solve the problem of scalable I/O-bound and CPU-bound operations—but in very different ways. So which one should you use? When is it better to go reactive, when do virtual threads make more sense, and is there ever a case to mix both?
This article breaks down these concurrency models, their trade-offs, and practical patterns for combining them effectively.
The Evolution: Reactive vs Virtual Threads
Reactive (Project Reactor)
Reactive programming focuses on non-blocking, asynchronous streams of data. It excels in:
- Handling high concurrency (e.g., thousands of connections)
- Streaming APIs (WebFlux, SSE)
- Event-driven architectures
Example with Reactor:
Mono.fromCallable(() -> service.fetchData())
.subscribeOn(Schedulers.boundedElastic())
.map(data -> process(data))
.subscribe(result -> System.out.println(result));
Virtual Threads (Structured Concurrency)
Java’s virtual threads, a major feature in Project Loom, allow you to write blocking code in a lightweight way, without the cost of OS threads.
Example with virtual threads (Java 21):
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> task = scope.fork(() -> service.fetchData());
scope.join();
String result = task.resultNow();
System.out.println(result);
}
Key Difference:
| Aspect | Project Reactor | Virtual Threads |
|---|---|---|
| Programming Model | Asynchronous (callbacks) | Synchronous (blocking-style) |
| Error Handling | Operators (onErrorResume) | Try/catch |
| Learning Curve | Steeper | Lower (feels like traditional Java) |
| Debugging Experience | Harder (stack traces) | Easier (like classic threads) |
| Library Compatibility | Not all blocking libraries work | Compatible with most blocking libraries |
| Performance (I/O) | Excellent | Excellent (with structured concurrency) |
When to Use Each Model
✅ Use Project Reactor When:
- Building streaming services (e.g., WebFlux, Kafka consumers)
- Need backpressure and event pipelining
- Working in reactive ecosystems (Spring WebFlux, RSocket)
- You want a dataflow style of concurrency
See: Spring WebFlux Guide
✅ Use Virtual Threads When:
- You prefer a traditional blocking code style but need scalability
- Building APIs that call external services (HTTP, DB)
- Using existing synchronous libraries without rewrite
- You want simpler code, fewer operators, and better readability
Mixing Reactor and Virtual Threads: Best of Both Worlds?
Yes, you can mix both in certain scenarios—but only when it makes architectural sense. Let’s look at a few practical patterns:
Pattern 1: Use Reactor at the edges, Virtual Threads inside
Use Project Reactor for HTTP request handling and streaming, then delegate internal blocking logic to virtual threads.
@GetMapping("/users")
public Mono<String> getUser() {
return Mono.fromCallable(() ->
VirtualThreadExecutor.submit(() -> userService.fetchUser())
).flatMap(Mono::fromFuture);
}
This avoids blocking the reactive thread pool while keeping the internal logic synchronous and readable.
Pattern 2: Parallel task fan-out using structured concurrency in reactive pipeline
Reactive isn’t always great for branching into multiple async tasks and waiting for all. Structured concurrency makes that easy.
Mono<String> result = Mono.fromCallable(() -> {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var a = scope.fork(() -> serviceA.call());
var b = scope.fork(() -> serviceB.call());
scope.join();
return a.resultNow() + b.resultNow();
}
});
Pattern 3: Virtual Threads for Legacy Code, Reactor for New APIs
When migrating to reactive, you don’t have to rewrite everything. Keep legacy blocking code running on virtual threads, while exposing reactive APIs externally.
Caveats and Anti-Patterns
- ❌ Don’t block Reactor threads (e.g.,
.block()inside a controller) - ❌ Don’t mix virtual threads with traditional thread pools (like
Executors.newFixedThreadPool)—useExecutors.newVirtualThreadPerTaskExecutor() - ❌ Avoid “reactive wrappers around blocking code” unless offloaded properly
- ❌ Structured concurrency needs careful scoping—leaked threads = bad
Tools and Frameworks That Support Mixing
| Framework | Virtual Threads Support | Reactor Support |
|---|---|---|
| Spring Boot 3.2+ | ✅ (via Tomcat/Jetty config) | ✅ Spring WebFlux |
| Micronaut | ✅ | ✅ (Reactor or RxJava) |
| Helidon Nima | ✅ Native virtual thread support | ✅ Reactive APIs |
| Vert.x | ❌ | ✅ Event loop model |
Final Thoughts
Reactive programming and virtual threads solve the same scalability problem, but from opposite angles:
- Reactor gives you fine-grained control, flow operators, and streaming power—but at the cost of complexity.
- Virtual threads give you simplicity, debuggability, and performance, especially when working with blocking I/O.
The best pattern depends on your use case:
- Prefer Reactor for data streams and event flows
- Prefer virtual threads for API backends, blocking services, and migration efforts
And if you need both—mix them with care using patterns like offloading or hybrid scopes.

