Reactive Programming vs Virtual Threads: Choosing the Right Concurrency Model in Java
For years, reactive programming with Project Reactor and RxJava was the answer to Java’s thread-per-request scaling problem. Now Java virtual threads offer a simpler alternative that achieves similar throughput without the same conceptual overhead. Therefore, this guide compares both approaches head-to-head, shows when each makes sense, and provides a practical migration path from a reactive stack to virtual threads.
The Problem Both Solve: Thread-Per-Request Doesn’t Scale
Traditional Java web servers allocate one platform thread per request. With 2,000 concurrent requests, you need 2,000 threads — each consuming roughly 1MB of stack memory. That’s about 2GB just for thread stacks, plus context-switching overhead. At 10,000 concurrent connections, the JVM spends more time switching threads than doing useful work.
Reactive frameworks solved this with non-blocking I/O on a small thread pool (typically CPU core count). A single thread handles hundreds of connections by never blocking — it processes events, starts I/O, and moves to the next event while I/O completes asynchronously. Virtual threads solve the same problem differently: they’re lightweight threads (~1KB vs ~1MB) that can block freely because the JVM parks them efficiently during I/O. Moreover, virtual threads look like regular blocking code — no Mono/Flux, no callback chains, no operator pipelines.
// Traditional blocking — doesn't scale past ~2K concurrent requests
@GetMapping("/orders/{id}")
public OrderDTO getOrder(@PathVariable Long id) {
Order order = orderRepo.findById(id); // Blocks platform thread
Customer customer = customerClient.get(order.getCustomerId()); // Blocks again
return new OrderDTO(order, customer);
}
// Reactive — scales to 100K+ connections but complex code
@GetMapping("/orders/{id}")
public Mono<OrderDTO> getOrder(@PathVariable Long id) {
return orderRepo.findById(id) // Returns Mono, non-blocking
.flatMap(order ->
customerClient.get(order.getCustomerId()) // Chain operations
.map(customer -> new OrderDTO(order, customer))
)
.onErrorResume(e -> Mono.error(new OrderNotFoundException(id)));
}
// Virtual threads — scales like reactive, reads like blocking
@GetMapping("/orders/{id}")
public OrderDTO getOrder(@PathVariable Long id) {
Order order = orderRepo.findById(id); // Blocks virtual thread (cheap)
Customer customer = customerClient.get(order.getCustomerId()); // Blocks again (still cheap)
return new OrderDTO(order, customer);
// Runs on a virtual thread — JVM handles the non-blocking I/O underneath
}
How Virtual Threads Actually Work Under the Hood
It helps to know what the JVM is doing, because the abstraction has sharp edges. A virtual thread is mounted onto a small pool of carrier platform threads. When your code hits a blocking I/O call that the JDK has been rewritten to understand — socket reads, java.net.http, modern JDBC drivers — the virtual thread is unmounted from its carrier and the carrier is freed to run another virtual thread. Once the I/O completes, the virtual thread is remounted and resumes exactly where it left off.
The important caveat is pinning. If a virtual thread blocks inside a synchronized block or a native call, it cannot be unmounted and the carrier stays parked, defeating the entire scaling model. In practice, the most common production pitfall is a legacy library using synchronized around I/O. Consequently, teams adopting virtual threads usually enable the JFR jdk.VirtualThreadPinned event and replace hot synchronized sections with ReentrantLock, which the runtime can unmount cleanly.
// BAD: synchronized pins the carrier thread during the HTTP call
class RateLimiter {
public synchronized Response call(Request r) {
return httpClient.send(r); // virtual thread cannot unmount here
}
}
// GOOD: ReentrantLock lets the virtual thread unmount during I/O
class RateLimiter {
private final ReentrantLock lock = new ReentrantLock();
public Response call(Request r) {
lock.lock();
try {
return httpClient.send(r); // carrier is freed during the wait
} finally {
lock.unlock();
}
}
}
When to Use Virtual Threads
Virtual threads are the right choice for most new I/O-bound applications. If your service primarily calls databases, HTTP APIs, and message brokers — and you spend most time waiting for I/O responses — virtual threads give you reactive-level throughput with blocking-style code. Furthermore, your existing libraries, debugging tools, and stack traces work unchanged.
// Spring Boot with virtual threads — just one property
// application.yml
// spring:
// threads:
// virtual:
// enabled: true
// Parallel I/O with virtual threads — structured concurrency
import java.util.concurrent.StructuredTaskScope;
public OrderDetails getOrderDetails(Long orderId) {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// All three calls execute in parallel on virtual threads
var orderFuture = scope.fork(() -> orderRepo.findById(orderId));
var paymentFuture = scope.fork(() -> paymentClient.getPayment(orderId));
var shippingFuture = scope.fork(() -> shippingClient.getTracking(orderId));
scope.join(); // Wait for all to complete
scope.throwIfFailed(); // Propagate any exception
return new OrderDetails(
orderFuture.get(),
paymentFuture.get(),
shippingFuture.get()
);
}
// Total time: max(order, payment, shipping) instead of sum
}
Key advantages of virtual threads: standard blocking code that developers already know, full stack traces for debugging instead of reactive assembly traces, compatibility with existing JDBC drivers, HTTP clients, and frameworks, and structured concurrency for parallel I/O with proper error handling. Crucially, a one-line exception in blocking code points straight at the offending statement, whereas an async failure often surfaces as a deep chain of operator frames.
When to Keep the Reactive Model
The reactive approach still has clear advantages for specific scenarios. If you need backpressure — controlling data flow when a consumer can’t keep up — Reactive Streams gives you a first-class protocol for it. Streaming data processing, such as reading a 10GB file and transforming it record by record without loading it all into memory, is naturally a streaming problem. Additionally, if you’re building event-driven pipelines with complex operator chains (combine, merge, retry with backoff, circuit breaker), declarative operators express these patterns more elegantly than imperative loops.
// Reactive excels at streaming with backpressure
// Processing a large dataset record-by-record without OOM
Flux.from(databaseCursorPublisher)
.bufferTimeout(100, Duration.ofMillis(500)) // Batch for efficiency
.flatMap(batch -> processAndEnrich(batch), 4) // 4 concurrent batches
.onBackpressureBuffer(1000) // Buffer if downstream is slow
.doOnNext(result -> metrics.incrementProcessed())
.subscribe(result -> outputSink.write(result));
// Complex retry logic expressed declaratively
webClient.get()
.uri("/api/data")
.retrieve()
.bodyToMono(Data.class)
.retryWhen(Retry.backoff(3, Duration.ofSeconds(1))
.filter(ex -> ex instanceof WebClientResponseException.ServiceUnavailable)
.onRetryExhaustedThrow((spec, signal) -> signal.failure())
)
.timeout(Duration.ofSeconds(10))
.onErrorResume(TimeoutException.class, e -> Mono.just(Data.fallback()));
One subtle point: virtual threads give you cheap concurrency, but they do not give you automatic backpressure. If 50,000 virtual threads each pull a row from a slow upstream, nothing throttles them — you can still exhaust a downstream connection pool or memory. The demand-driven model (request(n)) is the natural fit when a fast producer must be paced by a slow consumer.
Migration from Reactive to Virtual Threads
If you have an existing reactive codebase and want to migrate, do it incrementally. You don’t need to rewrite everything at once. Start with new endpoints using virtual threads while keeping existing reactive code running. Gradually migrate simple endpoints — those without backpressure or complex operators — to blocking code on virtual threads.
// Step 1: Keep reactive endpoints that benefit from it
// Step 2: New endpoints use virtual threads
// Step 3: Migrate simple endpoints
// Before: Simple reactive that doesn't need to be reactive
public Mono<UserDTO> getUser(Long id) {
return userRepo.findById(id)
.map(UserMapper::toDTO)
.switchIfEmpty(Mono.error(new UserNotFoundException(id)));
}
// After: Simpler blocking code on virtual threads
public UserDTO getUser(Long id) {
User user = userRepo.findById(id);
if (user == null) throw new UserNotFoundException(id);
return UserMapper.toDTO(user);
}
// Keep reactive: This genuinely benefits from reactive operators
public Flux<PriceUpdate> streamPrices(String symbol) {
return priceService.subscribe(symbol)
.filter(update -> update.getChange() > 0.01)
.sample(Duration.ofMillis(100))
.onBackpressureDrop();
}
Watch for two mixing hazards during migration. First, never call .block() on a Mono from inside a Netty event-loop thread — that is a classic deadlock; only block from a virtual thread or a dedicated bounded-elastic scheduler. Second, if your service mixes WebFlux controllers with virtual-thread MVC controllers, keep the two stacks in separate beans rather than bridging them ad hoc, because the threading assumptions differ and silent regressions hide there.
Performance Comparison and Trade-offs
In benchmarks with I/O-bound workloads (database queries, HTTP calls), virtual threads and reactive achieve comparable throughput — both routinely handle tens of thousands of concurrent connections on a single server. The difference shows up in CPU-bound scenarios: the reactive runtime tends to have slightly better CPU efficiency due to fewer context switches, while virtual threads use a little more memory per task. Consequently, for the vast majority of web services, the raw performance difference is negligible and the deciding factor is maintainability.
Where the honest trade-off bites is connection-pool sizing. With blocking code on virtual threads, you can accidentally launch far more concurrent database calls than your pool allows, turning a thread-pool bottleneck into a connection-pool stampede. The fix is the same discipline the async model forced on you — bound the concurrency explicitly with a semaphore or a sized executor — so the move to virtual threads is not a license to ignore capacity planning. The async model, by contrast, makes that pressure visible in the type system but pays for it with a steeper learning curve and harder debugging.
Related Reading:
- Spring Boot with Virtual Threads in Production
- Java Pattern Matching Guide
- Java 24 Virtual Threads and Structured Concurrency
- GraalVM Native Images with Spring Boot
Resources:
In conclusion, reactive programming is no longer the default answer to Java’s scaling problem — virtual threads give new applications reactive-level scalability with imperative simplicity, full stack traces, and unchanged libraries. Keep the reactive model for streaming data, genuine backpressure, and complex event-driven flows where its operators earn their keep. For existing reactive codebases, migrate incrementally, starting with the simplest endpoints and watching carefully for pinning and connection-pool limits.