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.