Back to Blog
java-21virtual-threadswebfluxreactiveperformancespring-boot

Java 21 Virtual Threads vs Reactive (WebFlux): Which One Should You Use? (2026)

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.

J

JOptimize Team

May 25, 2026· 9 min read

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.


The Problem Both Solve

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.


Enabling Virtual Threads in Spring Boot 3.2+

# 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.


Performance Comparison

For I/O-bound workloads (typical Spring Boot REST service), benchmarks show:

ApproachThroughput (req/s)P99 LatencyCode Complexity
Spring MVC (platform threads, 200)~15K50msLow
Spring MVC + virtual threads~80K45msLow
Spring WebFlux~85K42msHigh

Virtual threads get within 5-10% of WebFlux throughput with dramatically simpler code.

Where WebFlux still wins:

  • Non-blocking reactive drivers (R2DBC, reactive MongoDB) — the entire I/O stack is non-blocking
  • Backpressure — Flux handles producers faster than consumers natively
  • Streaming (SSE, WebSocket) — built into the reactive model
  • Very high connection counts (100K+) — event loop threads are still more efficient

Virtual Thread Pitfalls

Pinned Threads: The Hidden Trap

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

Thread-Local Caveats

// 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 });

WebFlux Is Still the Right Choice When...

// 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.


Decision Guide

SituationRecommendation
New CRUD service, Java 21Spring MVC + virtual threads
Existing Spring MVC codebaseAdd spring.threads.virtual.enabled=true
High-throughput streaming (SSE/WebSocket)WebFlux
All services use R2DBC/reactive driversWebFlux
Team unfamiliar with reactiveVirtual threads
Greenfield reactive microservicesEither — WebFlux has marginal perf edge

Common Mistakes to Avoid

  • Using synchronized inside virtual thread-heavy code — causes thread pinning; replace with ReentrantLock
  • Assuming virtual threads eliminate all threading bugs — race conditions and deadlocks still exist; virtual threads only change scheduling, not correctness
  • Migrating to WebFlux for performance alone — if your bottleneck is DB (slow queries), switching to reactive does nothing; fix the queries first
  • Mixing virtual threads with reactive operators — calling .block() on a reactive type inside a virtual thread works but defeats both models; pick one

Summary

Java 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.


Detect Threading Performance Issues

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.

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.