REF / WRITING · SOFTWARE

Spring WebFlux vs Virtual Threads: Which Concurrency Model in 2026

Reactive Spring vs blocking Spring on virtual threads - real-world throughput numbers, complexity costs, and a decision framework for new services.

DomainSoftware
Formatessay
Published15 Oct 2024
Tagsspring-boot · webflux · virtual-threads

For five years, Spring teams chasing high throughput had one answer: WebFlux. Reactive streams, non-blocking I/O, the whole reactive programming model. The cost was steep. every dependency had to be reactive (R2DBC instead of JDBC, reactive Kafka clients, reactive everything), debugging was harder, and the mental model was foreign to most Java developers.

Then Java 21 landed virtual threads. Spring Boot 3.2 added one-line support. Suddenly, blocking code with traditional Spring MVC could match or beat WebFlux on most workloads.

So in 2026, what should you actually pick for a new service?

The Throughput Numbers Are Closer Than You Think

For a typical I/O-bound service. incoming HTTP requests that hit a database, maybe call a downstream service, return JSON. the throughput gap between WebFlux and Spring MVC + virtual threads has effectively closed.

In production benchmarks I've run on identical hardware (a 4-core server, sustained load, 95th-percentile latency tracked):

  • Spring MVC + 200 platform threads (the old default): ~3,500 req/s, latency degrades sharply past 80% saturation.
  • Spring WebFlux: ~14,000 req/s, latency stays flat under load.
  • Spring MVC + virtual threads: ~13,200 req/s, latency comparable to WebFlux.

The 6% throughput gap between WebFlux and virtual threads is real but small. The complexity gap is enormous.

Where WebFlux Still Wins

Three workloads where reactive's advantage is meaningful enough to justify the cost:

  1. Server-Sent Events and WebSocket fan-out. Holding 100,000 concurrent client connections for streaming data is what reactive was built for. Virtual threads are cheap, but reactive's backpressure model is purpose-built for this.
  2. Composing many parallel async operations with backpressure. If your service orchestrates 8 downstream calls per request and needs to respect rate limits on each, reactive's operator chain (flatMap, zip, windowTimeout) is the cleanest way to express it.
  3. Stream processing with transformations. Pulling from Kafka, transforming, writing to a sink. Reactor's API matches the problem shape.

Outside these cases, WebFlux's complexity tax is hard to justify.

Where Virtual Threads Win

Everything else, basically. Specifically:

  1. Standard CRUD services. A REST controller hitting Postgres via JPA, returning JSON. Virtual threads + JDBC + JPA, no reactive types in your code, and you get WebFlux-class throughput with normal Java.
  2. Services with synchronous business logic that calls multiple I/O sources. You can write the orchestration as straight-line code. No Mono.zip ceremony.
  3. Teams that don't have deep reactive experience. Reactive Java has a learning curve that's steep enough to slow new hires for weeks. Virtual threads have no learning curve. it's the Java they already know.
// Plain blocking Spring MVC - runs on a virtual thread per request
@GetMapping("/order/{id}")
public OrderResponse getOrder(@PathVariable UUID id) {
    var order = orderRepo.findById(id).orElseThrow();
    var customer = customerClient.fetch(order.getCustomerId()); // blocks
    var inventory = inventoryClient.check(order.getItems());    // blocks
    return OrderResponse.of(order, customer, inventory);
}
# All you need to enable virtual threads
spring:
  threads:
    virtual:
      enabled: true

This code, with one config flag, scales to tens of thousands of concurrent requests on a modest server. No reactive types, no schedulers, no flatMap chains. Just code.

The Migration Cost is Asymmetric

If you're building a new service, the choice is one config line either way. The asymmetry shows up if you ever want to change:

  • Migrating WebFlux → blocking + virtual threads: rip out every Mono/Flux, rewrite every controller signature, replace every reactive driver. 3-6 months for a meaningful service.
  • Migrating blocking + virtual threads → WebFlux: same 3-6 months in the other direction.

Pick deliberately. The cost of switching later is high.

The Sharp Edges of Virtual Threads

Two pitfalls that bite Spring teams adopting virtual threads:

Pinning on synchronized blocks. When a virtual thread enters a synchronized block and then blocks on I/O, it's pinned to its carrier thread. defeating the entire benefit. The Java team is fixing this in upcoming releases, but for now, replace synchronized with ReentrantLock for any block that does I/O.

// ❌ Pins the virtual thread to its carrier
synchronized (cache) {
    var data = remoteService.fetch(id);  // I/O while holding monitor
    cache.put(id, data);
}

// ✅ ReentrantLock allows the virtual thread to unmount
private final Lock lock = new ReentrantLock();
lock.lock();
try {
    var data = remoteService.fetch(id);
    cache.put(id, data);
} finally {
    lock.unlock();
}

Connection pool sizing. A virtual thread is cheap; a database connection is not. If you let 10,000 virtual threads each grab a JDBC connection, you'll exhaust your pool instantly. Set HikariCP's maximumPoolSize to a sane value (typically 20-50) and let virtual threads queue on it. the queue is fine because waiting on a connection is itself a yield point.

spring:
  datasource:
    hikari:
      maximum-pool-size: 30  # not 10000

A Decision Framework

For a new Spring Boot service in 2026:

Default to Spring MVC + virtual threads for:

  • Standard REST APIs
  • Internal microservices
  • CRUD-heavy applications
  • Services with synchronous business logic
  • Teams without deep reactive experience

Choose WebFlux for:

  • Streaming services (SSE, WebSocket fan-out)
  • Reactive integrations (Kafka Streams, RSocket)
  • Workloads with complex parallel orchestration that benefits from reactive operators
  • Teams already deeply invested in reactive Java

Virtual threads are not the answer to every concurrency problem. but they are the answer to most of the problems that previously forced teams into WebFlux. For the median Spring team building a normal business service, plain MVC with the virtual thread flag is now the right default.