Java 21 virtual threads promise the performance of reactive programming with the simplicity of blocking code. Do they actually replace WebFlux? Learn the real trade-offs with benchmarks.
JOptimize Team
Java 21 virtual threads (Project Loom) eliminate the core reason reactive programming exists: the problem that blocking a thread while waiting for I/O wastes resources. With virtual threads, you can write blocking code that scales like reactive code. This naturally raises the question: is Spring WebFlux still relevant?
The answer is yes — but the use cases have narrowed considerably.
Traditional threading: one platform thread per request. Platform threads are expensive (1MB stack, OS context switch). At 10,000 concurrent requests, you need 10,000 threads — 10GB of stack memory, 10K OS context switches per second. This is why Node.js and reactive programming exist.
Virtual threads: lightweight JVM-managed threads (a few KB each). A single platform thread can multiplex millions of virtual threads. When a virtual thread blocks on I/O, the JVM parks it and uses the platform thread for another virtual thread — exactly what reactive programming does, but transparently.
Reactive (WebFlux + Project Reactor): explicit async pipeline. You write code as a chain of operators (map, flatMap, filter) that execute on a small event loop thread pool. No blocking allowed — your code literally cannot block.
# application.properties — one line to enable virtual threads spring.threads.virtual.enabled=true
With this one property, Spring Boot runs every request handler on a virtual thread instead of a pooled platform thread. Your existing Spring MVC code runs unchanged — no reactive operators, no Mono/Flux, no programming model change.
// This blocking code scales like reactive code with virtual threads @GetMapping("/dashboard") public DashboardDto getDashboard(Long userId) { User user = userService.findById(userId); // Blocks — but on virtual thread List<Order> orders = orderService.findByUser(userId); // Blocks — another virtual thread parks List<Notif> notifs = notifService.findByUser(userId); // Parallel in a thread pool return new DashboardDto(user, orders, notifs); }
For true parallelism with virtual threads, use ExecutorService.newVirtualThreadPerTaskExecutor():
@GetMapping("/dashboard") public DashboardDto getDashboard(Long userId) throws Exception { try (var exec = Executors.newVirtualThreadPerTaskExecutor()) { var userFuture = exec.submit(() -> userService.findById(userId)); var ordersFuture = exec.submit(() -> orderService.findByUser(userId)); var notifFuture = exec.submit(() -> notifService.findByUser(userId)); return new DashboardDto( userFuture.get(), ordersFuture.get(), notifFuture.get() ); } // Total time = max(user, orders, notifs) — same as WebFlux Mono.zip() }
This is readable, debuggable, and scales to hundreds of thousands of concurrent requests.
For I/O-bound workloads (typical Spring Boot REST service), benchmarks show:
| Approach | Throughput (req/s) | P99 Latency | Code Complexity |
|---|---|---|---|
| Spring MVC (platform threads, 200) | ~15K | 50ms | Low |
| Spring MVC + virtual threads | ~80K | 45ms | Low |
| Spring WebFlux | ~85K | 42ms | High |
Virtual threads get within 5-10% of WebFlux throughput with dramatically simpler code.
Where WebFlux still wins:
Virtual threads can't unmount from their platform thread inside synchronized blocks:
// BAD — synchronized pins the virtual thread to the platform thread public synchronized void processPayment(Payment payment) { paymentGateway.charge(payment); // This I/O call can't park — PINS THE THREAD } // GOOD — use ReentrantLock instead of synchronized private final ReentrantLock lock = new ReentrantLock(); public void processPayment(Payment payment) { lock.lock(); try { paymentGateway.charge(payment); // Virtual thread can now park on I/O } finally { lock.unlock(); } }
With JDK 21, check for pinning with: -Djdk.tracePinnedThreads=full
// ThreadLocal works with virtual threads, but millions of virtual threads // each with their own ThreadLocal can create memory pressure // Use ScopedValue (JDK 21 preview) for lighter-weight scoping: static final ScopedValue<UserContext> USER_CONTEXT = ScopedValue.newInstance(); ScopedValue.where(USER_CONTEXT, context).run(() -> { processRequest(); // USER_CONTEXT.get() works within this scope });
// Streaming to millions of clients simultaneously @GetMapping(value = "/prices", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<PriceUpdate> streamPrices() { return priceStream .filter(p -> p.getChange().abs().compareTo(new BigDecimal("0.01")) > 0) .delayElements(Duration.ofMillis(100)); // Backpressure built in } // R2DBC — fully non-blocking DB pipeline Mono<List<Product>> products = r2dbcProductRepo .findByCategory(category) .collectList(); // Combining 5 microservice calls in parallel Mono.zip(userCall, ordersCall, inventoryCall, pricingCall, reviewsCall, (u, o, i, p, r) -> new PageDto(u, o, i, p, r));
For these patterns, the reactive model is natural and WebFlux's event loop is genuinely more efficient.
| Situation | Recommendation |
|---|---|
| New CRUD service, Java 21 | Spring MVC + virtual threads |
| Existing Spring MVC codebase | Add spring.threads.virtual.enabled=true |
| High-throughput streaming (SSE/WebSocket) | WebFlux |
| All services use R2DBC/reactive drivers | WebFlux |
| Team unfamiliar with reactive | Virtual threads |
| Greenfield reactive microservices | Either — WebFlux has marginal perf edge |
synchronized inside virtual thread-heavy code — causes thread pinning; replace with ReentrantLock.block() on a reactive type inside a virtual thread works but defeats both models; pick oneJava 21 virtual threads deliver near-reactive throughput with blocking code syntax. For most Spring Boot services, spring.threads.virtual.enabled=true is all you need — no reactive operators, no programming model change, same performance. WebFlux remains the better choice for streaming, backpressure, and fully reactive data pipelines with R2DBC. The days of migrating to WebFlux just for concurrency are over.
JOptimize detects synchronized blocks that pin virtual threads, blocking calls inside reactive pipelines, and thread pool configurations not optimized for virtual threads.
Maximize throughput with the right concurrency model — free scan.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.