Project Loom’s Virtual Threads: Why Blocking Code Is Cool Again
For the last eight years, Java developers faced an uncomfortable choice: keep your code simple but sacrifice scalability, or embrace reactive programming with all its complexity to handle high concurrency. Libraries like Spring WebFlux and Project Reactor promised non-blocking I/O and massive throughput, but at the cost of flatMap() chains, cryptic stack traces, and a steep learning curve that frustrated even senior engineers.
Then Project Loom arrived in Java 21, and everything changed. Virtual threads—lightweight, JVM-managed threads that can number in the millions—eliminate the fundamental trade-off between simplicity and scalability. You can write straightforward blocking code that reads like synchronous logic, and it performs comparably to (or better than) reactive frameworks.
This isn’t incremental improvement. It’s a paradigm reset that makes a decade of reactive complexity obsolete for most I/O-bound applications. Let’s examine the data, the real-world migrations, and the strategic decision framework for 2026.
2025 became the first year Spring developers voluntarily removed WebFlux from production. The reason? Virtual Threads made blocking code scale better than reactive frameworks—without the complexity.
1. The Platform Thread Problem: Why We Had Reactive in the First Place
To understand why virtual threads matter, we need to revisit the problem they solve. Traditional Java threads (now called “platform threads”) have been a 1:1 mapping to operating system threads since Java 1.0. This design creates cascading constraints:
- Memory overhead: Each platform thread consumes approximately 1-2MB of stack space by default
- Creation cost: Thread creation involves expensive kernel syscalls, taking 100-300 microseconds per thread
- Context switching: OS scheduler overhead scales poorly beyond 1,000-5,000 threads
- Kernel limits: Most systems cap at 32,000-65,000 threads due to address space constraints
When you build a web application with traditional thread-per-request architecture, you quickly hit a wall. With Tomcat’s default thread pool of 200, you can handle 200 concurrent requests. If each request blocks waiting for a database query or API call, those threads sit idle—doing nothing but consuming memory and preventing new requests from being processed.
This is where reactive programming entered the picture. By using non-blocking I/O and callback-based asynchronous execution, frameworks like Spring WebFlux could multiplex thousands of concurrent operations across a small pool of threads. The trade-off? Your code transformed from this:
Blocking (Before Loom)
@GetMapping("/user/{id}")
public User getUser(@PathVariable String id) {
User user = userRepo.findById(id);
Order order = orderService.getOrder(user);
return enrichUser(user, order);
}
Reactive Complexity
@GetMapping("/user/{id}")
public Mono<User> getUser(@PathVariable String id) {
return userRepo.findById(id)
.flatMap(user ->
orderService.getOrder(user)
.map(order -> enrichUser(user, order))
);
}
The reactive version scales beautifully, but at what cost? The code becomes harder to read, debug, and maintain. Error handling gets convoluted. Stack traces become meaningless chains of reactor operators. Junior developers struggle for months to become productive. Even senior engineers find themselves fighting the framework rather than solving business problems.
2. Enter Virtual Threads: The JVM’s Game Changer
Virtual threads solve the platform thread problem by moving concurrency management from the OS kernel to the JVM. Here’s the core insight: when a virtual thread blocks on I/O, the JVM automatically:
- Captures the continuation (stack state + local variables)
- Parks the virtual thread and unmounts it from the carrier thread
- Reuses the carrier thread for other virtual threads
- Resumes the virtual thread when the blocking operation completes
This happens transparently. You write blocking code, but the JVM makes it non-blocking under the hood. Each virtual thread costs mere kilobytes instead of megabytes. Creation is cheap—you can spin up a million virtual threads without breaking a sweat. Context switching is managed by the JVM scheduler, which has perfect visibility into what’s actually blocking versus what’s computing.
The Result: You get reactive-level concurrency with imperative-style code. The complexity vanishes. The learning curve flattens. The debugging experience returns to normal. And performance? Often better than reactive frameworks.
3. Real Benchmarks: Virtual Threads vs WebFlux
Let’s cut through the marketing and look at actual data. Multiple independent benchmarks from 2025 comparing Spring Boot applications tell a consistent story.
Benchmark 1: I/O-Bound Database Operations
A comprehensive benchmark using Apache Bench tested 10,000 requests with 100 concurrent users performing database queries with simulated I/O delays:
Platform Threads (Traditional): - Time taken: 11.837 seconds - Requests per second: 844.80/sec - Average response: 118.3ms Virtual Threads: - Time taken: 8.527 seconds - Requests per second: 967.56/sec (+14.5%) - Average response: 110.3ms (-7%)
Benchmark 2: Real-World Microservice Simulation
Comprehensive benchmarks by Chris Gleissner tested Spring Boot microservices simulating real workloads (database queries + REST calls to external services):
The data reveals something crucial: Virtual Threads on Netty matched or exceeded WebFlux performance in 45% of test scenarios, while WebFlux won in only 30% of cases. The remaining scenarios showed no statistically significant difference. For high user count scenarios (5,000+ concurrent users), virtual threads consistently showed lower P90 and P99 latency.
Benchmark 3: When WebFlux Still Wins
Not every scenario favors virtual threads. Vincenzo Racca’s detailed comparison found that under extreme concurrency (800+ simultaneous users, 16,000 total requests), WebFlux maintained an edge:
| Concurrent Users | WebFlux Throughput | Virtual Threads Throughput | Winner |
|---|---|---|---|
| 100 | 270.4/sec | 258.2/sec | WebFlux (+4.7%) |
| 200 | 453.6/sec | 390.9/sec | WebFlux (+16%) |
| 400 | 611.7/sec | 595.8/sec | WebFlux (+2.7%) |
| 800 | 653.2/sec | 614.8/sec | WebFlux (+6.2%) |
The pattern is clear: when you push to extreme concurrency and use fully reactive database drivers (R2DBC), WebFlux maintains an advantage. But for typical enterprise workloads with moderate concurrency and blocking JDBC, virtual threads deliver comparable or better performance with dramatically simpler code.
4. Migration Stories: From Reactive to Simple
Case Study 1: The E-Commerce Platform
Mid-Size E-CommerceTeam: 8 developers | Codebase: 150K LOC WebFlux
The Problem: The team built their entire backend in Spring WebFlux in 2021. While it handled 5,000+ concurrent users admirably, development velocity had slowed to a crawl. Every new feature required wrestling with reactive operators. Junior developers took 4-6 months to become productive. Debugging production issues required specialized knowledge that only 2 team members possessed.
The Migration: They upgraded to Java 21 in September 2025 and began a gradual migration. They started by replacing the most problematic service—the product catalog, which had the most complex flatMap chains.
The Results:

“We kept the same throughput but cut our codebase by a third. More importantly, our junior developers can now contribute meaningfully within weeks instead of months,” said their tech lead.
Case Study 2: The Financial Services Platform
FinTech StartupTeam: 15 developers | 20 microservices | 500K daily users
The Problem: They built their payment processing system with WebFlux and R2DBC in 2022. The system handled load beautifully but had a critical flaw: debugging race conditions and subtle timing bugs in reactive code was nearly impossible. They spent weeks troubleshooting issues that would have been obvious in synchronous code.
The Migration Strategy: Rather than migrate everything, they adopted a hybrid approach. Core payment processing stayed on WebFlux with R2DBC for its true non-blocking advantages. Everything else—user management, reporting, admin tools—migrated to virtual threads with standard JDBC.
The Results:

“Virtual threads gave us back readable stack traces. That alone was worth the migration. We can now spin up 100,000 threads for batch processing jobs that previously required complex reactive orchestration,” their principal architect explained.
Case Study 3: The Migration That Failed
Streaming PlatformReal-time data processing | WebSockets | SSE
The Mistake: A streaming analytics platform tried migrating their entire WebFlux stack to virtual threads, including their real-time WebSocket connections and server-sent events (SSE) pipelines.
Why It Failed: Virtual threads excel at request-response patterns with blocking I/O. They’re not designed for long-lived streaming connections that need backpressure management. The migration introduced subtle memory leaks and failed to handle backpressure properly when clients couldn’t keep up with data streams.
The Lesson: They reverted to WebFlux for streaming endpoints and kept virtual threads for their REST APIs. “We learned that reactive programming still has its place. Virtual threads aren’t a silver bullet—they’re a tool for specific use cases,” admitted their CTO.
5. The Technical Deep Dive: How Virtual Threads Actually Work
Let’s get into the mechanics. Virtual threads are built on continuations—a JVM feature that captures and restores thread state. When you call Executors.newVirtualThreadPerTaskExecutor(), you’re creating an executor that:
- Spawns a new virtual thread for each submitted task
- Mounts that virtual thread on a carrier thread (a regular platform thread)
- Monitors for blocking operations via the JVM’s bytecode instrumentation
- When blocking is detected, parks the virtual thread and unmounts it
- When the operation completes, remounts the virtual thread (potentially on a different carrier)
Spring Boot Configuration
@Configuration
public class VirtualThreadConfig {
@Bean
public TomcatProtocolHandlerCustomizer<?>
protocolHandlerVirtualThreadExecutorCustomizer() {
return protocolHandler -> {
protocolHandler.setExecutor(
Executors.newVirtualThreadPerTaskExecutor()
);
};
}
}
application.properties (Spring Boot 3.3+)
# Even simpler - one line! spring.threads.virtual.enabled=true # That's it. Now every HTTP request # runs on a virtual thread automatically.
Pinning: The One Gotcha You Must Know
Virtual threads have one critical limitation: pinning. When a virtual thread executes code inside a synchronized block or calls a native method, it cannot unmount from its carrier thread. The virtual thread remains “pinned” to the carrier, blocking other virtual threads from using it.
Avoid These Patterns:
- Using
synchronizedin hot paths—useReentrantLockinstead - Long-running synchronized blocks that perform I/O
- Heavy use of legacy libraries that rely on
synchronized
The JVM will log warnings when pinning occurs excessively. Monitor with -Djdk.tracePinnedThreads=full during development.
6. The Decision Matrix: When to Choose What
After analyzing dozens of migrations and benchmarks, here’s the practical decision framework for 2026:
Use Virtual Threads When:
- Building REST APIs with typical I/O patterns
- Working with blocking JDBC databases
- Your team values code simplicity
- Migrating from traditional Spring MVC
- Concurrency needs are moderate (<10K concurrent)
- You want readable stack traces
- Debugging complexity is a concern
Use WebFlux/Reactive When:
- Building streaming APIs (WebSockets, SSE)
- Need fine-grained backpressure control
- Extreme concurrency (>50K concurrent)
- Using fully reactive databases (R2DBC)
- Building event-driven architectures
- Your team is already expert in reactive
- Processing infinite streams of data
Hybrid Approach When:
- Mix of streaming and request-response
- Some services need extreme scale
- Gradual migration from reactive
- Different services have different needs
- Want to minimize risk during transition
- Complex microservice architecture
7. Virtual Threads in the Ecosystem: What Works Today
The Java ecosystem has rapidly adapted to virtual threads. Here’s what’s ready for production in February 2026:
| Technology | Virtual Thread Support | Notes |
|---|---|---|
| Spring Boot 3.3+ | Full Support | One-line configuration, works with Tomcat and Netty |
| Hibernate 6.2+ | Compatible | No changes needed, connection pools scale higher |
| Spring Data JDBC | Full Support | Perfect match, maintains simplicity |
| Apache Kafka | Works Well | Consumer threads can be virtual, improves throughput |
| HikariCP | Compatible | Connection pools work transparently |
| RestTemplate/WebClient | Both Work | RestTemplate becomes viable again for high concurrency |
| R2DBC | Less Relevant | JDBC with virtual threads often performs better |
8. Migration Guide: From WebFlux to Virtual Threads
If you’re sitting on a WebFlux codebase and considering migration, here’s the proven step-by-step approach:
Phase 1: Assessment (Week 1-2)
- Identify which services are truly reactive (streaming, backpressure) vs accidentally reactive (just wanted concurrency)
- Check for synchronized blocks in hot paths that would cause pinning
- Review your database drivers—can you use JDBC or do you need R2DBC?
- Establish baseline performance metrics before any changes
Phase 2: Pilot Migration (Week 3-4)
- Choose one low-risk service with simple reactive chains
- Upgrade to Java 21+ and Spring Boot 3.3+
- Enable virtual threads:
spring.threads.virtual.enabled=true - Replace
Mono/Fluxreturn types with plain objects - Replace
flatMap/mapchains with sequential calls - Switch from R2DBC to JDBC if applicable
Before: WebFlux Service
@Service
public class OrderService {
@Autowired
private ReactiveMongoRepository repo;
@Autowired
private WebClient paymentClient;
public Mono<Order> processOrder(String id) {
return repo.findById(id)
.flatMap(order ->
validateInventory(order)
.flatMap(valid ->
processPayment(order)
.map(payment ->
completeOrder(order, payment)
)
)
);
}
}
After: Virtual Threads
@Service
public class OrderService {
@Autowired
private MongoRepository repo;
@Autowired
private RestClient paymentClient;
public Order processOrder(String id) {
Order order = repo.findById(id)
.orElseThrow();
validateInventory(order);
Payment payment = processPayment(order);
return completeOrder(order, payment);
}
}
Phase 3: Expand and Optimize (Month 2-3)
- Migrate additional services based on pilot learnings
- Tune database connection pools (can increase significantly)
- Monitor for pinning with JVM flags
- Measure and compare performance against reactive baseline
- Train team on virtual thread best practices
Phase 4: Production Hardening (Month 4+)
- Implement structured concurrency for complex workflows
- Add observability for virtual thread metrics
- Refactor any remaining synchronized blocks to ReentrantLock
- Document architecture decisions and patterns
- Decide which services stay reactive (streaming, etc.)
9. Performance Tuning: Getting the Most from Virtual Threads
Database Connection Pools
With virtual threads, you can dramatically increase connection pool sizes without the traditional memory overhead. HikariCP configuration that works well:
spring.datasource.hikari.maximum-pool-size=1000 spring.datasource.hikari.minimum-idle=100 spring.datasource.hikari.connection-timeout=30000 spring.datasource.hikari.idle-timeout=600000
Traditional thread pools could never support 1,000 connections because you’d need 1,000+ platform threads. With virtual threads, this becomes practical and often optimal.
Structured Concurrency
One of the most powerful features introduced alongside virtual threads is structured concurrency, which ensures that subtasks complete before their parent scope exits:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<User> userFuture = scope.fork(() -> fetchUser(userId));
Future<Orders> ordersFuture = scope.fork(() -> fetchOrders(userId));
Future<Preferences> prefsFuture = scope.fork(() -> fetchPrefs(userId));
scope.join(); // Wait for all tasks
scope.throwIfFailed(); // Propagate exceptions
return new Dashboard(
userFuture.resultNow(),
ordersFuture.resultNow(),
prefsFuture.resultNow()
);
}
This pattern replaces complex CompletableFuture orchestration with clear, maintainable code that properly handles cancellation and error propagation.
10. The Economics: ROI of Migration
Let’s talk numbers. Based on actual migration reports from 2025:
The average migration from WebFlux to virtual threads shows:
- 35% code reduction (fewer operators, simpler error handling)
- 40% faster feature development (less complexity tax)
- 60% reduction in debugging time (readable stack traces)
- 50% faster onboarding (junior devs productive faster)
- Same or better performance (often 10-20% improvement for typical workloads)
The migration cost is typically 2-4 weeks for a mid-size service. The payback period? Usually under 3 months based on reduced maintenance burden alone.
11. What We’ve Learned
Project Loom’s virtual threads, released in Java 21, represent a fundamental shift in Java concurrency. They eliminate the decade-long trade-off between simple blocking code and scalable non-blocking reactive frameworks. Virtual threads are JVM-managed, lightweight threads that automatically yield when blocking, allowing millions of concurrent operations with minimal overhead.
Real-world benchmarks consistently show virtual threads matching or exceeding Spring WebFlux performance in 45% of scenarios, with WebFlux maintaining an edge only in extreme concurrency situations (50K+ concurrent users) or when using fully reactive databases. For typical enterprise workloads with moderate concurrency and blocking JDBC, virtual threads deliver comparable throughput with dramatically simpler code.
Production migrations in 2025 demonstrated remarkable results: 35% code reduction, 40% faster development, 60% easier debugging, and 50% faster team onboarding—all while maintaining or improving performance. Companies voluntarily removed WebFlux from production not because it failed, but because virtual threads made its complexity unnecessary for most use cases.
The decision framework is clear: use virtual threads for REST APIs, request-response patterns, and traditional JDBC workloads. Keep WebFlux for streaming endpoints, backpressure-critical systems, and truly event-driven architectures. Most teams will adopt a hybrid approach, using the right tool for each specific service.
Virtual threads aren’t just a performance optimization—they’re a developer experience revolution. They return Java to its strengths: simple, readable, debuggable code that scales effortlessly. After eight years of reactive complexity, blocking code is cool again.



