Virtual threads vs reactive

"Do virtual threads make WebFlux obsolete?"

What they are

Virtual threads (JEP 444, final in JDK 21) are lightweight threads scheduled by the JVM rather than the operating system. When a virtual thread blocks on I/O, the JVM unmounts its continuation from the underlying carrier thread and parks it on the heap, freeing the carrier to run something else. You write ordinary blocking code; the runtime makes it non-blocking underneath.

Reactive (Reactive Streams, implemented by Project Reactor and RxJava) is a different thing entirely: a push-based dataflow protocol in which a subscriber signals demand with request(n) and the publisher may emit no more than that. The programming model is a pipeline of operators; the payoff is that backpressure is expressed in the protocol rather than in a buffer somewhere.

The question conflates two properties that reactive bundles together. Reactive gives you (a) non-blocking I/O with a small thread pool and (b) explicit demand signalling. Virtual threads give you (a) and not (b). So the honest answer is: virtual threads remove the main reason most teams adopted reactive, and do not replace what reactive uniquely provides.

The problem virtual threads solve

Before Loom, a JVM service handling 10,000 concurrent requests with the thread-per-request model needed 10,000 platform threads. Each carries a stack reservation on the order of a megabyte and an OS-level context switch cost, so the model stopped scaling somewhere in the low thousands. The industry's answer was asynchronous, non-blocking I/O with a small event loop, and reactive frameworks were the ergonomic wrapper around that.

The cost was enormous and mostly unremarked: stack traces became useless, debuggers stopped stepping through logic, thread-local state (including MDC logging and security contexts) broke, profilers attributed time to the wrong place, and every developer had to learn a new mental model. That cost is what virtual threads remove. You get the scalability without the rewrite.

Mechanics

The two models, side by side

// Blocking, on a virtual thread. Reads top to bottom. Stack traces are real.
// Errors are exceptions. try/finally works. Debuggers step through it.
@GetMapping("/orders/{id}")
public OrderView get(@PathVariable String id) {
    Order order = orderRepo.findById(id);            // blocks; VT unmounts
    Customer customer = customerClient.fetch(order.customerId());  // blocks
    Inventory inv = inventoryClient.check(order.lines());          // blocks
    return OrderView.of(order, customer, inv);
}
// Reactive. Composition is explicit; nothing blocks; backpressure propagates.
@GetMapping("/orders/{id}")
public Mono<OrderView> get(@PathVariable String id) {
    return orderRepo.findById(id)
        .flatMap(order -> Mono.zip(
                customerClient.fetch(order.customerId()),
                inventoryClient.check(order.lines()))
            .map(t -> OrderView.of(order, t.getT1(), t.getT2())));
}

Both scale to high concurrency. The first is readable by anyone; the second requires knowing what zip does, which scheduler the work runs on, and why the stack trace has forty frames of reactor.core.publisher.

Turning virtual threads on in Spring Boot 3.2 or later is one property:

spring.threads.virtual.enabled=true

Structured concurrency, for the parallel case

The blocking version above runs the two downstream calls sequentially. Structured concurrency (a preview feature through several JDK releases) makes them concurrent without giving up the readable shape:

// Both calls run concurrently; the scope joins them, propagates the first
// failure, and cancels the sibling. Scope closes => nothing leaks.
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    var customer  = scope.fork(() -> customerClient.fetch(order.customerId()));
    var inventory = scope.fork(() -> inventoryClient.check(order.lines()));
    scope.join().throwIfFailed();
    return OrderView.of(order, customer.get(), inventory.get());
}

That is the reactive zip with a stack trace and a try block.

Pinning, and how the advice changed

A virtual thread that cannot unmount is pinned to its carrier, which turns your small carrier pool back into the bottleneck. Historically the two causes were synchronized blocks and native (JNI) frames.

The advice through JDK 21 to 23 was to replace synchronized with ReentrantLock on any lock held across a blocking call. JDK 24 changed this (JEP 491): virtual threads no longer pin the carrier for synchronized in the common cases. Native frames and class initialisers still pin.

Know both states and say which one you are on. Quoting the ReentrantLock advice without the version qualifier is a tell that the knowledge is second-hand.

Detection: -Djdk.tracePinnedThreads=full on JDK 21 (deprecated later), or the jdk.VirtualThreadPinned JFR event, which is the current mechanism.

Do not pool virtual threads

// Wrong: a pool exists to limit expensive resources. Virtual threads are cheap.
var pool = Executors.newFixedThreadPool(200, Thread.ofVirtual().factory());

// Right: one virtual thread per task, unbounded.
var exec = Executors.newVirtualThreadPerTaskExecutor();

// And separately, bound the thing that is actually scarce.
private final Semaphore downstream = new Semaphore(25);  // matched to the pool

This is the single most important operational point and it is where the comparison to reactive gets interesting. Virtual threads make threads free. They do not make downstream capacity free. Remove the thread pool and you have removed the accidental rate limiter that was protecting your database, and you will discover its real limit under load. The replacement is an explicit Semaphore, a bulkhead, or a connection pool sized deliberately, and that is exactly the concurrency argument you had to make with reactive's flatMap concurrency parameter.

What reactive still has that virtual threads do not

Demand signalling across a network boundary. If a consumer is slower than a producer, reactive's request(n) tells the producer to slow down. With blocking code the equivalent is a bounded queue and a blocking put, which works in-process and does not extend across a service boundary. For a streaming pipeline where the producer is remote, this is a real capability gap.

Streaming semantics as a first-class type. Flux<T> is an unbounded stream with operators for windowing, buffering, sampling and merging. Modelling that with blocking code means Stream, an Iterator, or a queue, none of which compose the same way.

Complex asynchronous composition. Fan out to five services, take the first three responses, retry two of them with backoff, and time the whole thing out. Reactive expresses that in five operators. Structured concurrency is getting there and is not there yet.

A worked example: the migration decision

A service fronting three downstream calls, 2,000 requests per second, p99 of 120 ms, currently Spring MVC on a 200-thread pool.

Little's Law first. Concurrency = throughput x latency = $2000 \times 0.120 = 240$ concurrent requests. The 200-thread pool is already the ceiling: queueing starts before the downstreams do. This is a real constraint and it is why the team is considering a rewrite.

Option A, reactive rewrite. Estimated at one quarter of team time. Delivers the concurrency. Costs: every engineer learns Reactor, MDC-based logging breaks and needs Context propagation, the JDBC driver must be replaced with R2DBC (which changes the transaction story), and every future stack trace is worse.

Option B, virtual threads. One property in application.properties, plus an audit for synchronized blocks held across blocking calls and a Semaphore in front of each downstream client. Delivers the same concurrency. Estimated at one week including load testing.

Option C, raise the thread pool to 400. Costs one config line. 400 platform threads at roughly a megabyte of stack reservation each is fine on a modern machine. This is worth naming explicitly, because it is often the correct answer and nobody proposes it.

For this service, C then B. Raise the pool now to unblock, adopt virtual threads in the next release for headroom, and keep the code shape. Option A would be correct if the requirement were streaming a live feed to clients with real backpressure, and it is not.

The instructive part is what B forces you to confront: the moment threads stop being the limit, the database connection pool becomes it. Little's Law again, 240 concurrent requests against a pool of 50 caps you at $50 / 0.120 = 417$ requests per second regardless of how many threads you have. The virtual-thread migration does not fix that; it makes it visible.

Production evidence

Netflix published a detailed write-up of a virtual-threads incident in their Spring Boot 3.2 / JDK 21 services ("Java 21 Virtual Threads: Dude, Where's My Lock?", Netflix Technology Blog, 2024): intermittent hangs traced to virtual threads pinned by synchronized blocks, with the carrier pool exhausted and every virtual thread waiting on a lock held by a pinned one. It is the best public account of the pinning failure mode and worth citing by name.

Spring Boot 3.2 shipped spring.threads.virtual.enabled as a single property, which is the clearest signal from the framework side that the intended migration path for most services is virtual threads rather than a reactive rewrite.

JEP 444 (virtual threads, final in JDK 21) and JEP 491 (synchronised blocks no longer pin, JDK 24) are the primary sources for the mechanics and for the change in the pinning advice.

Project Reactor remains the engine under Spring WebFlux, Spring Cloud Gateway and the reactive Spring Data drivers, which is the honest counterweight: an enormous amount of production Java runs on it and it is not going away.

The debate

The case for reactive in 2026: it is the right model for genuine streaming with backpressure across a network boundary, for complex asynchronous composition that structured concurrency does not yet express well, and for teams already fluent in it, where the switching cost is real and the benefit is zero.

The case for virtual threads: for the overwhelming majority of services, the reason to adopt reactive was concurrency, and virtual threads deliver that without the cognitive and observability cost. Debuggers work, stack traces are readable, thread-locals work, profilers attribute correctly, and a new hire is productive on day one.

My position, and it is the one to say out loud: for a new service on JDK 21 or later, default to virtual threads with structured concurrency. Choose reactive only when you need real streaming with backpressure across a network boundary. And do not rewrite a working reactive service for this; the cost is real and the benefit is developer experience, which is worth something but not a quarter.

Virtual threads are the wrong answer when the workload is CPU-bound, because they solve a blocking-I/O problem and CPU work is limited by cores; when you are on a JDK older than 21; or when a critical dependency pins (a native library, or synchronized held across I/O on a pre-JDK-24 runtime). They are also wrong as a substitute for admission control: making threads free removes the accidental limit that was protecting your downstreams.

Follow-up Q&A

"Do virtual threads make WebFlux obsolete?" They remove the main reason most teams adopted it, which was scaling I/O-bound concurrency without a huge thread pool. They do not replace what reactive uniquely provides: demand signalling across a network boundary, streaming as a first-class type, and complex async composition. So for a new CRUD or API-aggregation service on JDK 21 or later I default to virtual threads and structured concurrency, and I reach for reactive when I need real backpressure on a stream.

"What is pinning and how do you detect it?" A virtual thread that cannot unmount from its carrier, so the carrier is blocked for the duration. Native frames and class initialisers pin. synchronized blocks pinned through JDK 23 and no longer do in the common cases as of JDK 24 (JEP 491), so the answer depends on your runtime version. Detect it with the jdk.VirtualThreadPinned JFR event, or -Djdk.tracePinnedThreads on JDK 21. Netflix published the canonical incident: carriers exhausted by pinned threads, service hanging, no obvious error.

"You switched to virtual threads and throughput did not improve. Why?" Because threads were not the bottleneck. Check the connection pool first: Little's Law says a pool of N at latency L caps you at $N/L$ requests per second no matter how many threads you have. Then check whether a downstream service is the limit, in which case you have simply moved the queue. Then check for pinning. Virtual threads raise a ceiling; if a different ceiling is lower, nothing changes.

"How do you limit concurrency once threads are free?" Explicitly, with a Semaphore per downstream, sized from the downstream's capacity, or with a bulkhead per workload class so a slow dependency cannot consume all your in-flight budget. This is the same reasoning as passing a concurrency argument to Reactor's flatMap, and it is the piece people forget: the old thread pool was doing double duty as a rate limiter, and removing it removes that protection.

"Why does MDC logging break in reactive but not with virtual threads?" MDC is implemented on a thread-local. In reactive, a request's processing hops threads between operators, so the thread-local does not follow it; you have to propagate through Reactor's Context and bridge it back at logging time. With virtual threads the request stays on one (virtual) thread for its whole life, so thread-locals work exactly as before. This is a good concrete example of the observability cost reactive imposes, and it is the one most teams hit first.

Common misconceptions

The biggest is that virtual threads make code faster. They do not. A single request takes exactly as long; what changes is how many can be in flight for a given amount of memory. Throughput improves only if threads were the constraint.

The second is that you should pool them. Pools exist to ration expensive resources, and virtual threads are cheap. Use one per task and bound the scarce resource separately.

The third is quoting the synchronized pinning advice without a version. It was correct through JDK 23 and changed in 24, and the version qualifier is what separates current knowledge from a two-year-old blog post.

Interview delivery note

Say this: "For a new service on JDK 21 or later I default to virtual threads and structured concurrency. They give me the concurrency that made people adopt reactive, without losing stack traces, debuggers, thread-locals or profiler attribution. I choose reactive when I need genuine streaming with backpressure across a network boundary, which is a real capability virtual threads do not have, because request(n) demand signalling is the whole point of Reactive Streams. And I would not rewrite a working reactive service for this."

The depth signal is what comes next: "The thing I would watch in the migration is that the thread pool was also acting as an accidental rate limiter. Once threads are free, the connection pool becomes the ceiling, so I would put an explicit semaphore in front of each downstream sized from its actual capacity." That sentence shows you have thought past the framework comparison to the operational consequence.

Further reading

  • JEP 444, "Virtual Threads" (final in JDK 21), and JEP 491, "Synchronize Virtual Threads without Pinning" (JDK 24), for the mechanics and the change in pinning behaviour.
  • Netflix Technology Blog, "Java 21 Virtual Threads: Dude, Where's My Lock?" (2024), for the production pinning incident.
  • The Reactive Streams specification, particularly the request(n) demand protocol, for what reactive provides that threads do not.
  • JEP 453 and its successors on Structured Concurrency, for the scoped-fork-and-join model that replaces reactive's composition operators.