Java
    August 25, 202615 min read

    We Replaced Spring WebFlux with Virtual Threads. Here's the Honest Result.

    After 3 years of reactive streams in production, we migrated four services from WebFlux to Spring Boot + virtual threads. Code complexity halved, p99 latency dropped 34%, onboarding time for new engineers went from weeks to days. Here's exactly what we did — and what we kept reactive.

    Share

    Three years ago I would have told you Spring WebFlux was the future of high-throughput Java services.

    I was wrong — for most services.

    This isn't a hot take. It's a post-mortem. In late 2025 we migrated four production microservices off WebFlux onto Spring Boot 3.3 with virtual threads. The results were good enough that I owe it to the Java community to write them down honestly — including the two services where we kept reactive, and why.


    Why We Chose WebFlux in the First Place

    Let's be fair to past-us. The decision made sense in 2022:

    • We were running on 2-core Fargate tasks with 512MB RAM
    • The services were mostly I/O-bound (database + downstream HTTP calls)
    • We'd seen the benchmarks — WebFlux could handle 10× the concurrent requests on the same hardware compared to a blocking Spring MVC stack
    • The team had a few engineers who genuinely understood Project Reactor

    We got what we paid for. Thread counts stayed low. Latency was good. The services scaled cheaply.

    So why did we migrate?


    The Cost That Doesn't Show Up in Benchmarks

    Reactive programming has a tax. You pay it in developer time.

    Stack traces became archaeological digs. A NullPointerException 12 Mono operators deep produces a stack trace 40 frames long, most of which is Reactor internals. Debugging a production issue meant peeling back the operator chain layer by layer, mentally reconstructing the execution path.

    Context propagation was a constant source of bugs. MDC logging, security context, distributed tracing — all of these depend on ThreadLocal state that simply doesn't work in a reactive pipeline. We had to manually propagate ReactiveSecurityContextHolder, wrap tracing spans into the Reactor Context, and write a custom MdcContextLifter. This was boilerplate we wrote once and then forgot about until it broke during an upgrade.

    Onboarding new engineers took weeks. Not days. Weeks. We'd hand someone a Mono<Flux<ResponseEntity<List<OrderDTO>>>> return type and watch their eyes glaze over. Before they could write a feature, they needed to understand flatMap vs concatMap, cold vs hot publishers, and why you should never call a blocking method inside a reactive chain.

    Testing was painful. StepVerifier is a great API if you know it well. If you don't, writing a test for a Flux that emits three elements and then an error is not intuitive. PRs would sit in review while engineers debated whether verifyComplete() or verify() was the right call to make.

    The business impact: slower feature delivery, a higher defect rate on reactive-specific bugs, and an engineering team that visibly preferred working on our two Spring MVC services.


    The Architecture: Before and After

    graph TD subgraph BEFORE ["Before — Spring WebFlux (Netty)"] A1[HTTP Request] --> B1[Reactive Handler] B1 -->|Mono flatMap| C1[R2DBC Query] B1 -->|Mono flatMap| D1[WebClient HTTP] C1 -->|zip| E1[Operator Chain] D1 -->|zip| E1 E1 -->|map/filter| F1[Response] G1[2 Netty Event Loop Threads] -.->|shared| B1 G1 -.->|shared| C1 G1 -.->|shared| D1 end subgraph AFTER ["After — Spring MVC + Virtual Threads (Tomcat)"] A2[HTTP Request] --> B2[Blocking Handler] B2 -->|direct call| C2[JDBC Query] B2 -->|direct call| D2[RestClient HTTP] C2 --> E2[Plain Java Result] D2 --> E2 E2 --> F2[Response] G2[Virtual Thread per Request] -.->|1:1| B2 end

    The reactive model uses a small fixed pool of event loop threads that never block — hence the complexity of the operator chains. Virtual threads let you write blocking code while the JVM parks the virtual thread during I/O instead of burning an OS thread. You get the same concurrency benefit without the reactive programming model.


    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:

    Metric WebFlux Virtual Threads Change
    OS threads (peak load) 18 22 +22%
    Virtual threads (peak load) ~12,000 created on demand
    Heap per instance 340 MB 290 MB −15%
    p50 latency 42 ms 38 ms −10%
    p99 latency 187 ms 123 ms −34%
    Throughput (req/s) 2,100 2,690 +28%
    Lines of application code 8,400 4,960 −41%

    [!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.

    <!-- Remove -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webflux</artifactId>
    </dependency>
    
    <!-- Add -->
    <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
    • WebClientRestClient

    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

    • Java 21+ confirmed on all targets
    • spring-boot-starter-webflux removed, spring-boot-starter-web added
    • spring.threads.virtual.enabled=true set
    • R2DBC replaced with JDBC (or keep R2DBC only where reactive data access is intentional)
    • WebClient replaced with RestClient
    • Reactive handlers rewritten to blocking (return T, not Mono<T>)
    • Reactor-specific error handling moved to @ControllerAdvice
    • MDC/security/tracing boilerplate deleted (it now works by default)
    • HikariCP pool size set explicitly
    • StepVerifier-based tests rewritten as plain JUnit
    • Load test before and after — verify p99 improvement
    • Keep reactive where the domain demands it (SSE, Kafka streams, WebSocket)

    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.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Was this helpful?
    AY
    Avaneesh Yadav

    I build enterprise AI systems — Spring AI, RAG, and agents — and write about shipping LLMs to production. I also run advisory and workshops for engineering teams.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Join engineers building production AI systems

    Comments