The Code Difference
Here's the same endpoint — fetching an order with its line items from DB and enriching it with real-time pricing from a downstream service — in both models.
WebFlux version:
@GetMapping("/orders/{id}")
public Mono<ResponseEntity<EnrichedOrder>> getOrder(@PathVariable String id) {
return orderRepository.findById(id)
.switchIfEmpty(Mono.error(new OrderNotFoundException(id)))
.flatMap(order ->
lineItemRepository.findByOrderId(order.getId())
.collectList()
.flatMap(items ->
pricingClient.getCurrentPrices(
items.stream()
.map(LineItem::getSkuId)
.collect(Collectors.toList()))
.map(prices -> EnrichedOrder.from(order, items, prices))
)
)
.map(ResponseEntity::ok)
.onErrorResume(OrderNotFoundException.class,
ex -> Mono.just(ResponseEntity.notFound().build()))
.onErrorResume(ex -> {
log.error("Failed to fetch order {}", id, ex);
return Mono.just(ResponseEntity.internalServerError().build());
});
}
Virtual threads version:
@GetMapping("/orders/{id}")
public ResponseEntity<EnrichedOrder> getOrder(@PathVariable String id) {
Order order = orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
List<LineItem> items = lineItemRepository.findByOrderId(order.getId());
Map<String, BigDecimal> prices = pricingClient.getCurrentPrices(
items.stream().map(LineItem::getSkuId).toList()
);
return ResponseEntity.ok(EnrichedOrder.from(order, items, prices));
}
The WebFlux version: 25 lines, five operator types, three indentation levels, two error handlers. The virtual threads version: 10 lines, sequential flow, a single orElseThrow. The @ControllerAdvice handles exceptions globally — no per-endpoint error wiring.
This is the same logic. The same I/O operations. The same result. One version is readable by any Java developer; the other requires fluency in Reactor.
The Numbers
We ran load tests on all four services before and after migration. Here's the aggregate:
[!NOTE]
p99 improvement was the surprise. We expected similar p99 or a slight regression — instead it improved significantly. Our hypothesis: the reactive operator chain adds non-trivial scheduling overhead under high concurrency (subscriptions, emissions, backpressure signals), whereas virtual threads park cleanly on I/O and incur no overhead when I/O resolves quickly.
OS thread count went up slightly because Tomcat uses carrier threads to mount virtual threads — but those carrier threads are idle most of the time. Real memory stays flat.
How to Migrate: The Exact Steps
Step 1: Update pom.xml
Remove spring-boot-starter-webflux. Add spring-boot-starter-web.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
If you use R2DBC, replace it with JDBC + your driver. If you use WebClient, replace it with RestClient (Spring 6.1+) or keep RestTemplate.
Step 2: Enable virtual threads
In application.properties (Spring Boot 3.2+):
spring.threads.virtual.enabled=true
That's it. Spring Boot automatically configures Tomcat to use virtual threads. No executor beans, no @Async wiring.
[!NOTE]
Requires Java 21+. On Java 21 virtual threads are GA. On Java 24 (current LTS candidate as of 2026) there are additional performance improvements to virtual thread pinning.
Step 3: Rewrite reactive handlers to blocking
The pattern is mechanical:
Mono<T> return → T return
.block() calls → direct method calls (now safe because virtual thread parks, not blocks)
Flux<T> → List<T> (for batch queries)
switchIfEmpty(Mono.error(...)) → .orElseThrow(...)
.onErrorResume(...) → try/catch or @ControllerAdvice
WebClient → RestClient
Step 4: Replace reactive data access
R2DBC is reactive-only. Switch to JDBC + Spring Data JPA or Spring Data JDBC. Connection pool should be HikariCP (default in Spring Boot). Set maximum-pool-size to something reasonable — virtual threads multiply fast but JDBC connections don't, so set a ceiling:
spring.datasource.hikari.maximum-pool-size=50
[!WARNING]
Do not set this to a huge number. With virtual threads you might be tempted to increase the pool size dramatically because OS threads are cheap. Don't. The database has a fixed connection limit. maximum-pool-size=50 means at most 50 concurrent DB operations; the rest of your virtual threads will wait efficiently. This is correct and expected behavior — virtual thread parking on a pool semaphore is free.
Step 5: Fix ThreadLocal-dependent code
The good news: MDC logging, Spring Security context, and Micrometer tracing all work out of the box with virtual threads — they use ThreadLocal which is per-virtual-thread. No MdcContextLifter, no manual context propagation. Delete all that boilerplate.
Step 6: Run your tests
Your integration tests (if they hit a real database) will largely pass. Unit tests that used StepVerifier need rewriting — now they're plain JUnit assertions. This is the most time-consuming part of the migration, not the application code.
What We Kept Reactive — and Why
We did not migrate everything. Two cases where WebFlux stayed:
Server-Sent Events endpoint. We have a /stream/events endpoint that pushes live updates to browser clients over SSE. Flux with a Sinks.Many is genuinely the right tool for this — it models a push stream naturally, handles backpressure, and integrates with Spring's SSE support. Virtual threads don't help here; the request handler needs to hold a channel open and push events asynchronously.
Kafka consumer pipeline. One service does heavy stream processing: consume → transform → filter → aggregate → produce. This is a Flux-from-Kafka pipeline with window, groupBy, and reduce operators. The reactive model fits the domain perfectly. Rewriting it to blocking code would require re-implementing the windowing and aggregation logic manually.
The rule we arrived at: reactive is the right tool when your data is inherently a stream. SSE, WebSocket, event sourcing, stream processing — these are genuinely reactive workloads. Request/response services that happen to do I/O are not.
Migration Checklist
The Verdict
Virtual threads win for 80% of microservices. If your service is I/O-bound and does request/response work, the migration is worth doing. You get comparable or better performance, dramatically simpler code, and a team that can onboard new engineers in days instead of weeks.
Reactive still wins at the edges: true streaming endpoints, event-driven pipelines, and situations where back-pressure is a genuine domain requirement (not just a concurrency pattern).
The interesting implication: the argument for WebFlux in new greenfield services has collapsed. Unless you know you're building a streaming service, start with Spring MVC + virtual threads. You can always add a reactive endpoint later. You cannot easily un-reactive a whole service once the operator chains are everywhere.
We're Java developers. We like reading code top to bottom. It turns out the JVM will let us do that again — without sacrificing throughput.
Have you migrated from WebFlux to virtual threads? I'd love to hear what your numbers looked like — especially if they contradict mine. Find me on LinkedIn or leave a comment below.