Spring Boot Virtual Threads vs Reactive Programming
Spring Boot virtual threads have fundamentally changed how Java developers handle concurrency. With Project Loom now stable in Java 21+ and Spring Boot 3.2+ offering first-class support, teams face a critical architectural decision: should you adopt virtual threads or stick with reactive programming using WebFlux? The answer is rarely absolute, and this guide gives you a framework rather than a slogan.
This comparison draws on the published Spring and OpenJDK guidance plus widely reproduced community benchmarks, covering performance characteristics, code-complexity trade-offs, and migration strategies. By the end, you will have a clear way to choose the right concurrency model for your specific workload instead of following hype in either direction.
Understanding the Concurrency Models
Traditional Spring Boot applications use platform threads — one thread per request. This model is simple but doesn’t scale well under high concurrency because each platform thread maps to an OS thread and reserves a sizable stack. Reactive programming with WebFlux solved this by using non-blocking I/O on a small pool of event-loop threads, trading away imperative readability for raw scalability.
Virtual threads take a different approach. They are lightweight threads managed by the JVM that can be created in the millions. When a virtual thread blocks on I/O, the JVM unmounts it and frees the underlying platform thread (called the carrier) to run other work. As a result, you get much of the scalability of reactive programming with the simplicity of plain imperative code and ordinary stack traces.
Virtual Threads in Spring Boot 3.2+
Enabling them requires minimal configuration. Add a single property and the framework wires virtual-thread executors into Tomcat, @Async, and scheduled tasks:
# application.yml
spring:
threads:
virtual:
enabled: true
# For Tomcat specifically
server:
tomcat:
threads:
max: 200 # Platform threads (virtual threads don't need this)
With this single property, every request handler runs on a virtual thread. Your existing blocking code — JDBC calls, RestTemplate, file I/O — all benefit automatically without any code changes, provided the underlying library actually yields rather than holding a native lock.
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
private final InventoryClient inventoryClient;
private final PaymentClient paymentClient;
// This blocking code now runs on virtual threads
@PostMapping
public ResponseEntity<Order> createOrder(@RequestBody OrderRequest request) {
// Each blocking call unmounts the virtual thread
var inventory = inventoryClient.checkAvailability(request.getItems());
var payment = paymentClient.authorize(request.getPaymentInfo());
var order = orderService.create(request, inventory, payment);
return ResponseEntity.ok(order);
}
// Parallel execution with structured concurrency
@GetMapping("/{id}/details")
public ResponseEntity<OrderDetails> getOrderDetails(@PathVariable Long id) {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var orderFuture = scope.fork(() -> orderService.findById(id));
var shipmentFuture = scope.fork(() -> shipmentClient.getStatus(id));
var invoiceFuture = scope.fork(() -> invoiceClient.getInvoice(id));
scope.join().throwIfFailed();
return ResponseEntity.ok(new OrderDetails(
orderFuture.get(), shipmentFuture.get(), invoiceFuture.get()
));
}
}
}
The Pinning Trap You Must Avoid
Virtual threads are not magic. A virtual thread can become pinned to its carrier — meaning it cannot unmount — when it blocks inside a synchronized block or during a native call. If pinning happens on a hot path, your carrier pool starves and throughput collapses back to platform-thread levels, often worse. Older connection pools and some legacy drivers that guard critical sections with synchronized are common culprits.
Fortunately, the fix is usually mechanical. Replace synchronized blocks around I/O with ReentrantLock, which is Loom-aware and allows unmounting. The JVM can surface pinning events, so enabling that diagnostic during load testing is the standard way teams catch the problem before production.
// Anti-pattern: synchronized around I/O pins the carrier thread
public synchronized Token refresh() { return remoteCall(); }
// Loom-friendly: ReentrantLock lets the virtual thread unmount on I/O
private final ReentrantLock lock = new ReentrantLock();
public Token refresh() {
lock.lock();
try {
return remoteCall();
} finally {
lock.unlock();
}
}
// Run with -Djdk.tracePinnedThreads=full to surface pinning during tests
Performance: Representative Numbers
The numbers below are representative of commonly published benchmarks for an I/O-bound REST API (a service calling PostgreSQL plus two downstream HTTP dependencies) on a modest 4 vCPU / 8 GB host. Treat them as orders of magnitude, not precise guarantees — your latency profile, pool sizing, and dependency behavior will shift them.
Workload: 1000 concurrent users, ~100ms DB latency, ~50ms HTTP latency
┌─────────────────────┬──────────┬──────────┬───────────────┐
│ Metric │ Platform │ Virtual │ WebFlux │
│ │ Threads │ Threads │ (Reactive) │
├─────────────────────┼──────────┼──────────┼───────────────┤
│ Throughput (req/s) │ ~850 │ ~4,200 │ ~4,500 │
│ p99 Latency (ms) │ ~2,100 │ ~185 │ ~170 │
│ Memory Usage (MB) │ ~1,800 │ ~420 │ ~380 │
│ CPU Usage (%) │ ~35 │ ~62 │ ~58 │
│ Thread Count │ 200 │ 1,000+ │ 16 (event) │
│ Code Complexity │ Low │ Low │ High │
└─────────────────────┴──────────┴──────────┴───────────────┘
The pattern these benchmarks consistently show is clear: virtual threads reach roughly 90%+ of reactive throughput with dramatically simpler code, while using a fraction of the memory of the platform-thread model. The remaining gap between virtual threads and WebFlux is small enough that, for most enterprise services, it is dwarfed by network and database latency rather than the concurrency model itself.
When Virtual Threads Win
Virtual threads excel in I/O-bound workloads where most time is spent waiting for database queries, HTTP calls, or file operations. Consequently, they are the ideal choice for typical enterprise applications — CRUD APIs, microservices calling other services, and batch processing that fans out across many remote calls.
The decisive advantage is code simplicity. Your team writes normal imperative Java, debugging works with ordinary stack traces, and testing uses familiar patterns. There is no learning curve for operators like flatMap, zip, or switchIfEmpty, and onboarding a new developer does not require teaching an entire reactive mental model.
When Reactive Still Wins
Reactive programming keeps real advantages in streaming scenarios — real-time data feeds, Server-Sent Events, WebSocket connections, and backpressure-sensitive pipelines. Virtual threads have no built-in backpressure mechanism, so if a fast producer can overwhelm a slow consumer, Reactor’s demand-based flow control is genuinely valuable rather than ceremonial.
Additionally, if your entire stack is already reactive — R2DBC, WebClient, reactive Kafka — switching to virtual threads would mean a large rewrite for marginal gain. In that situation the pragmatic move is to leave working code alone.
// Reactive is still better for streaming use cases
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<StockPrice>> streamPrices() {
return stockService.getPriceStream()
.map(price -> ServerSentEvent.<StockPrice>builder()
.data(price)
.event("price-update")
.build())
.onBackpressureDrop();
}
Migration Strategy: From WebFlux to Virtual Threads
If you have decided to migrate from reactive to virtual threads, the docs and practitioners recommend an incremental approach rather than a big-bang rewrite. Convert one vertical slice — a single controller and its downstream calls — validate it under load, then proceed.
// Step 1: Replace WebClient with RestClient (blocking, virtual-thread safe)
@Service
public class UserServiceV2 {
private final RestClient restClient;
// Before (reactive)
public Mono<User> getUserReactive(String id) {
return webClient.get()
.uri("/users/{id}", id)
.retrieve()
.bodyToMono(User.class);
}
// After (virtual threads + RestClient)
public User getUser(String id) {
return restClient.get()
.uri("/users/{id}", id)
.retrieve()
.body(User.class);
}
}
Furthermore, replace R2DBC with JDBC or Spring Data JPA. Virtual threads handle blocking JDBC calls efficiently, and you regain the full power of JPA — lazy loading, complex queries, and stored procedures that were awkward under R2DBC. One caution during migration: size your database connection pool deliberately. Millions of virtual threads can each try to grab a connection, so the pool — not the thread count — becomes your real concurrency limit, and an undersized pool will quietly serialize requests.
Decision Framework
Use this guidance for new projects:
- Choose Virtual Threads if: I/O-bound workload, team prefers imperative code, you rely on JDBC/JPA, standard REST APIs, fan-out batch processing.
- Choose WebFlux if: streaming / SSE / WebSocket heavy, existing reactive codebase, you need real backpressure control, event-driven pipelines.
- Choose Platform Threads if: CPU-bound work (encoding, heavy computation), very low concurrency, or you are stuck on Java 8/11.
Key Takeaways
Virtual threads are becoming the default concurrency model for most Java applications because they deliver near-reactive performance with imperative simplicity. As a result, new Spring Boot projects should generally start with virtual threads unless they have specific streaming or backpressure requirements that reactive handles better.
For existing reactive codebases, migration is optional — WebFlux continues to work well and is still the right tool for genuinely event-streaming systems. However, if your team struggles with reactive debugging or onboarding, virtual threads offer a credible, lower-friction path forward.
Related Reading
- Spring Boot Virtual Threads in Production
- Reactive vs Virtual Threads 2026
- Java 24 Virtual Threads & Structured Concurrency
- Project Reactor Advanced Operators & Backpressure
External Resources
In conclusion, choosing between Spring Boot virtual threads and reactive programming comes down to the shape of your workload, not fashion. By applying the patterns covered here — measuring under realistic load, watching for pinning, sizing pools correctly, and reserving WebFlux for true streaming — you can build systems that are scalable, maintainable, and a great deal easier to debug.