Virtual Threads vs Reactive: When to Pick Each
The Problem They Both Solve
Threads are expensive. A platform thread in the JVM consumes roughly 1MB of stack space and requires an OS-level context switch every time it is scheduled. This becomes a serious constraint for I/O-bound applications: a traditional thread pool-based server processing 10,000 concurrent requests needs 10,000 threads, 10GB of memory just for stacks, and the scheduler's cooperation to keep them all moving.
Two solutions emerged. The reactive approach (Project Reactor, RxJava, Vert.x) handles I/O non-blockingly by pushing work through event loops and callback chains. Virtual threads, released as a production feature in Java 21, handle I/O by parking cheaply on a small number of OS threads and resuming transparently.
Both achieve high concurrency. They take completely different paths to get there.
The Models
Platform Threads (the problem)
Every request needs a thread. Every thread blocks on I/O. Thread pool exhausted = request queuing = high latency.
Virtual Threads (Java 21)
Virtual threads are managed by the JVM, not the OS. When a virtual thread blocks on I/O, the JVM parks it and unmounts it from its carrier (platform) thread. The carrier thread is then free to run another virtual thread.
// Exactly the same blocking code you always wrote
// Virtual threads make it scale
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
executor.submit(() -> {
// This blocks — but the carrier thread is NOT blocked
String result = httpClient.get("/api/data");
process(result);
});
}
}The key insight: you write familiar, imperative, blocking code. The JVM makes it non-blocking behind the scenes.
Reactive Programming
Reactive handles I/O by never blocking at all. Work is expressed as a pipeline of operators. An event loop drives execution. No thread waits — it subscribes to a result and gets called back.
// Reactive — non-blocking but complex
public Mono<String> processRequest(String input) {
return webClient.get()
.uri("/api/data")
.retrieve()
.bodyToMono(String.class)
.flatMap(data -> processAsync(data))
.onErrorResume(e -> Mono.just("fallback"))
.timeout(Duration.ofSeconds(5));
}The pipeline is powerful. It also fundamentally changes how you think about code flow, error handling, and debugging.
The Trade-offs
When Virtual Threads Win
Standard REST APIs and microservices
If you are running a Spring Boot application that handles HTTP requests involving database queries, cache lookups, and downstream HTTP calls, virtual threads are the answer. The migration is almost trivial:
// Spring Boot 3.2+ — enable virtual threads in one line
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
return protocolHandler -> {
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
};
}
}Or with Spring Boot 3.2+ spring.threads.virtual.enabled=true property — that is literally all it takes.
Benchmark results from a Spring Boot 3.2 application with 10ms simulated database latency:
Concurrency comparison — 10ms I/O latency, 4 core machine
─────────────────────────────────────────────────────────
Thread pool Virtual threads
Throughput (req/s) 4,100 42,800
p50 latency 12ms 11ms
p99 latency 380ms 14ms
Memory (heap) 2.1GB 0.8GB
─────────────────────────────────────────────────────────The throughput improvement is dramatic for I/O-bound workloads. The p99 latency improvement is even more important — the thread-pool p99 reflects queuing under load.
JDBC and blocking libraries
All existing JDBC drivers, HTTP clients, and blocking I/O libraries work with virtual threads without any code changes. This is the decisive advantage over reactive, which requires reactive-native drivers for every I/O operation.
// Works perfectly with virtual threads — no changes needed
@Repository
public class UserRepository {
@Transactional
public User findById(long id) {
// This blocks — virtual thread parks, carrier continues
return jdbcTemplate.queryForObject(
"SELECT * FROM users WHERE id = ?",
userRowMapper, id
);
}
}When Reactive Wins
Streaming and server-sent events
When you are streaming data to many clients over a long-lived connection, reactive's backpressure model is essential. Virtual threads do not give you backpressure — a slow consumer will still consume memory proportional to the unprocessed data.
// Reactive — natural fit for streaming
@GetMapping(value = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> streamEvents() {
return Flux.interval(Duration.ofSeconds(1))
.map(sequence -> ServerSentEvent.<String>builder()
.id(String.valueOf(sequence))
.event("price-update")
.data(priceService.getCurrentPrice())
.build())
.onBackpressureDrop(); // Drop events for slow consumers
}Complex async pipelines with fan-out
When you need to call three services in parallel, combine their results, apply transformations, and handle partial failures with fallbacks, reactive operators give you a clean, composable way to express it.
// Reactive fan-out and composition
public Mono<OrderSummary> buildOrderSummary(String orderId) {
return Mono.zip(
orderClient.getOrder(orderId),
inventoryClient.getStock(orderId),
shippingClient.getStatus(orderId)
)
.map(tuple -> OrderSummary.from(
tuple.getT1(), tuple.getT2(), tuple.getT3()
))
.onErrorResume(InventoryException.class,
e -> buildPartialSummary(orderId));
}This is expressible with virtual threads and CompletableFuture, but the reactive operator model is more ergonomic for deeply nested parallel flows.
The Pinning Problem
The main footgun with virtual threads is pinning. When a virtual thread executes code inside a synchronized block while blocking on I/O, it cannot unmount from the carrier thread. The carrier thread is pinned and unavailable for other virtual threads.
// This pins the carrier thread — avoid
synchronized (lock) {
String result = httpClient.get("/api/data"); // Blocks AND is inside synchronized
}
// Use ReentrantLock instead
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
String result = httpClient.get("/api/data"); // VT can park here
} finally {
lock.unlock();
}Detect pinning with JVM flags:
-Djdk.tracePinnedThreads=fullThis logs a stack trace every time a carrier thread is pinned. Run this during load testing to find pinning hot spots before production.
Migration Path from Platform Threads
If you are on Spring Boot 2.x or a traditional thread-pool-based application:
The migration is genuinely easy. The vast majority of codebases need only the property change. Pinning fixes are surgical — run the pinning tracer and fix only what it surfaces.
The Decision Framework
Answer these questions:
- Is your workload I/O-bound (DB, HTTP, cache)? Both help. Virtual threads are simpler.
- Do you need to stream data to clients (SSE, WebSocket)? Use reactive.
- Do you need fine-grained backpressure? Use reactive.
- Are you migrating an existing blocking application? Virtual threads — minimal code changes.
- Is your team already comfortable with reactive programming? Stay reactive.
- Are you starting fresh? Virtual threads unless you need streaming or backpressure.
The answer for most enterprise backend services in 2026 is virtual threads. They give you the concurrency of reactive with the simplicity of blocking code. Reactive remains the right answer for streaming workloads, real-time pipelines, and cases where backpressure is non-negotiable.
The choice is not permanent. You can migrate from one to the other. Pick based on the problem you have today.