Java Virtual Threads vs Reactive: When to Drop WebFlux for Project Loom
I wanted virtual threads to be the silver bullet; they were just a better knife. “We rewrote to WebFlux for scalability.” Now you have 10x the code complexity and developers who can’t debug it.
Java 21 Virtual Threads promise the same scalability with regular blocking code. Is WebFlux finally obsolete?
Tested on: Java 21, Spring Boot 3.2, WebFlux 6.1, PostgreSQL 16, 10k concurrent connections
The Scalability Problem
Platform Threads (Traditional)
// Each request = one OS thread
// OS thread = ~1MB stack + kernel overhead
// 10,000 concurrent requests = 10GB RAM just for stacks
@GetMapping("/blocking")
public String blocking() {
// Thread blocked waiting for DB
return repository.findById(1).getName(); // Thread pool: 200 threads max
}
// Result: 200 concurrent requests max, rest queued
Reactive (WebFlux)
// Non-blocking, event-driven
// Few threads handle many requests
@GetMapping("/reactive")
public Mono<String> reactive() {
return repository.findById(1)
.map(Entity::getName); // Non-blocking
}
// Result: 10,000+ concurrent requests possible
// Cost: Complex code, difficult debugging
Virtual Threads (Java 21)
// Lightweight threads, JVM-managed
// Blocking code, non-blocking execution
@GetMapping("/virtual")
public String virtual() {
// Looks blocking, but JVM unmounts thread during wait
return repository.findById(1).getName(); // Simple!
}
// Result: 10,000+ concurrent requests with blocking code
How Virtual Threads Work
Platform Thread vs Virtual Thread
Platform Thread:
┌─────────────────────────────────┐
│ OS Kernel Thread │ ~1MB stack
│ ├── Your code │
│ └── Blocked on I/O (waiting) │ Wasted!
└─────────────────────────────────┘
Virtual Thread:
┌─────────────────────────────────┐
│ Carrier Thread (platform) │ Shared pool
│ ├── VT1: executing code │
│ ├── VT2: executing code │
│ └── ... │
└─────────────────────────────────┘
↓ When VT blocks on I/O
┌─────────────────────────────────┐
│ VT unmounted from carrier │
│ Carrier runs another VT │ No waste!
│ VT remounted when I/O complete │
└─────────────────────────────────┘
Enabling Virtual Threads
// Spring Boot 3.2+
// application.yml
spring:
threads:
virtual:
enabled: true
// Or programmatically
@Bean
public TomcatProtocolHandlerCustomizer<?> virtualThreads() {
return handler -> handler.setExecutor(
Executors.newVirtualThreadPerTaskExecutor()
);
}
Benchmark Setup
Test Application
@RestController
public class BenchmarkController {
@Autowired
private JdbcTemplate jdbc;
// Simulates typical API: DB query + some processing
@GetMapping("/api")
public Response handle() {
// 50ms DB query
var data = jdbc.queryForObject(
"SELECT pg_sleep(0.05), current_timestamp",
(rs, i) -> rs.getTimestamp(2)
);
// 10ms CPU work
doSomeProcessing();
return new Response(data);
}
}
Load Test
// k6_benchmark.js
import http from 'k6/http';
export const options = {
scenarios: {
concurrent_users: {
executor: 'constant-vus',
vus: 10000, // 10k concurrent
duration: '5m',
},
},
};
export default function () {
http.get('http://localhost:8080/api');
}
Results: 10k Concurrent Connections
Platform Threads (Tomcat default: 200 threads)
Configuration:
server.tomcat.threads.max: 200
Results:
Throughput: 180 req/s
Latency p50: 850ms
Latency p99: 4,200ms
Errors: 12% (connection timeout)
Memory: 1.2GB
Problem: 200 threads, 10k requests = 9,800 queued
WebFlux + R2DBC
Configuration:
WebFlux with Netty
R2DBC PostgreSQL driver
Results:
Throughput: 8,500 req/s
Latency p50: 58ms
Latency p99: 180ms
Errors: 0.1%
Memory: 850MB
Complexity: ~3x more code, Mono/Flux everywhere
Virtual Threads + JDBC
Configuration:
spring.threads.virtual.enabled: true
Standard JDBC (blocking driver)
Results:
Throughput: 7,200 req/s
Latency p50: 62ms
Latency p99: 195ms
Errors: 0.1%
Memory: 920MB
Complexity: Same as traditional blocking code!
Summary Table
| Metric | Platform Threads | WebFlux | Virtual Threads |
|---|---|---|---|
| Throughput | 180 req/s | 8,500 req/s | 7,200 req/s |
| Latency p99 | 4,200ms | 180ms | 195ms |
| Code complexity | Simple | Complex | Simple |
| Debugging | Easy | Hard | Easy |
| Memory | 1.2GB | 850MB | 920MB |
When to Use What
Virtual Threads Win
// I/O-bound workloads with simple logic
// Traditional blocking libraries (JDBC, RestTemplate)
@Service
public class OrderService {
public Order createOrder(OrderRequest request) {
// All blocking calls, but VT makes them efficient
var inventory = inventoryClient.check(request); // HTTP call
var payment = paymentClient.charge(request); // HTTP call
var order = orderRepository.save(request); // DB call
emailService.send(order); // SMTP
return order;
}
}
WebFlux Wins
// Streaming data
// Backpressure requirements
// Already have reactive ecosystem
@GetMapping(value = "/stream", produces = TEXT_EVENT_STREAM_VALUE)
public Flux<Event> stream() {
return eventRepository
.findAllAsStream()
.delayElements(Duration.ofMillis(100))
.onBackpressureBuffer(1000);
}
// Server-Sent Events, WebSocket with backpressure
// Virtual Threads can't do this elegantly
Platform Threads Win
// CPU-bound workloads
// Low concurrency (< 200 concurrent requests)
// When simplicity matters most
@GetMapping("/compute")
public Result heavyComputation() {
// CPU-intensive, not I/O
// Virtual threads won't help here
return compute.intensive().operation();
}
Virtual Thread Gotchas
1. Pinning: synchronized Blocks
// BAD: VT gets pinned to carrier thread
public synchronized void pinnedMethod() {
database.query(); // Carrier thread blocked!
}
// GOOD: Use ReentrantLock
private final Lock lock = new ReentrantLock();
public void unpinnedMethod() {
lock.lock();
try {
database.query(); // VT can unmount
} finally {
lock.unlock();
}
}
2. Thread-Local Heavy Usage
// Thread-locals work but may accumulate
// Each VT gets its own copy
// Consider ScopedValue (Java 21 preview) for request context
ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
ScopedValue.runWhere(CURRENT_USER, user, () -> {
// user available in this scope
processRequest();
});
3. Pool-based Libraries
// Some libraries pool threads internally
// May not benefit from virtual threads
// Check library compatibility:
// - HikariCP: Works well
// - Apache HttpClient 5: Good VT support
// - OkHttp: Works but pinning in some cases
// - Lettuce (Redis): Reactive, doesn't need VT
Migration Path
From Platform Threads
# Step 1: Enable virtual threads
spring:
threads:
virtual:
enabled: true
# Step 2: Increase connection pool
spring:
datasource:
hikari:
maximum-pool-size: 100 # More connections needed
# Step 3: Test under load
# Step 4: Monitor for pinning
From WebFlux
// Gradual migration possible
// Keep reactive for streaming endpoints
// Convert simple endpoints:
// Before (WebFlux)
@GetMapping("/user/{id}")
public Mono<User> getUser(@PathVariable Long id) {
return userRepository.findById(id);
}
// After (Virtual Threads + JDBC)
@GetMapping("/user/{id}")
public User getUser(@PathVariable Long id) {
return userRepository.findById(id).orElseThrow();
}
Monitoring Virtual Threads
JFR Events
# Record virtual thread events
java -XX:StartFlightRecording=name=vt,settings=profile \
-jar app.jar
# Analyze pinning events
jfr print --events jdk.VirtualThreadPinned recording.jfr
Prometheus Metrics
// Micrometer + VT metrics
@Bean
MeterBinder virtualThreadMetrics() {
return registry -> {
Gauge.builder("jvm.threads.virtual.count", () ->
Thread.getAllStackTraces().keySet().stream()
.filter(Thread::isVirtual)
.count()
).register(registry);
};
}
Checklist
## Virtual Threads Migration
### Prerequisites
- [ ] Java 21+
- [ ] Spring Boot 3.2+
- [ ] Check library compatibility (JDBC drivers, HTTP clients)
### Configuration
- [ ] Enable spring.threads.virtual.enabled
- [ ] Increase connection pool size
- [ ] Review synchronized blocks (convert to Lock)
### Testing
- [ ] Load test with target concurrency
- [ ] Monitor for pinning (JFR events)
- [ ] Compare latency/throughput with baseline
### Keep Reactive For
- [ ] Streaming endpoints (SSE, WebSocket)
- [ ] Backpressure requirements
- [ ] Already reactive dependencies (R2DBC, WebClient)
Conclusion
Virtual Threads in Java 21 change the game:
- Same scalability as WebFlux with blocking code
- Simpler debugging - stack traces work normally
- Easy migration - one config change
- WebFlux still wins for streaming and backpressure
For most CRUD APIs: Virtual Threads > WebFlux.
Related Articles
- Connection Pool Sizing with Little’s Law - Pool sizing
- K8s CPU Throttling Autopsy - Resource tuning
Related posts
JVM Native Memory in Kubernetes: Why Your Pod Gets OOMKilled with 50% Heap
Heap is 50% full but pod gets OOMKilled. I'll show how to track native memory (Metaspace, threads, NIO) and prevent container memory issues.
When Prepared Statements Make PostgreSQL 10× Slower: Generic Plan Trap
Same query, same params, but prod is slow and staging works fine. I'll show how to reproduce the generic plan problem with pgBouncer, Java/Go and how to fix it.
Circuit Breaker vs Rate Limiter vs Bulkhead: When to Use Which Pattern
Three resilience patterns that are often confused. I'll show exactly when each prevents cascading failures and when it makes things worse with real metrics.
Kubernetes CPU Throttling Autopsy: Why p99 Latency Explodes at 40% CPU Usage
CPU looks OK but tail latency is catastrophic. I'll show how to correlate CFS throttling with latency spikes and why removing CPU limits can paradoxically help.
Cite this article
If you reference this post, please link to the original URL and credit the author.