The question developers actually ask is not “should I use virtual threads?” — it’s “should I migrate my existing WebFlux service to virtual threads, or is reactive still the right call?” This post answers it with three identical Spring Boot endpoints — an I/O-bound one and a CPU-bound one — each benchmarked under platform threads, virtual threads, and Spring WebFlux (Reactor), re-run for real on Spring Boot 4.1.1 and JDK 25. The previous version of this post benchmarked Boot 3.4.0 / JDK 21.0.3 on an assumed 8-core desktop with wrk; neither is available in this rewrite’s build environment, so every number below comes from this run, on a real 2 vCPU sandbox, using this repo’s own load generator — including a genuinely counter-intuitive result a first-cut benchmark got wrong before a second look explained why, and a benchmark-methodology bug of its own that silently swung which threading model looked faster until it was caught and fixed.
Versions this was verified against. Spring Boot 4.1.1 (GA, 20 August 2026), Spring Framework 7.0.9, Eclipse Temurin JDK 25.0.4.1 LTS. Companion code: virtual-threads-benchmark (platform vs virtual threads) and virtual-threads-benchmark-webflux (the WebFlux leg) — every number and transcript below is underdocs/output/in one of those two modules, regenerated by each module’s ownscripts/run-all.sh.
The three threading models
A Spring Boot application answers each HTTP request using one of three underlying execution models. The code you write can look almost identical across two of them; the third asks you to write in a different style entirely.
| Model | How it works | Spring Boot setup | Code style |
|---|---|---|---|
| Platform Threads | One OS thread per request, borrowed from a fixed pool (Tomcat default: 200). A request queues once the pool is exhausted. | Default. No config needed. | Blocking, imperative |
| Virtual Threads | One JVM-managed thread per request, unmounted from its carrier OS thread whenever it blocks — the carrier is freed instantly and reused. | spring.threads.virtual.enabled=true | Blocking, imperative (identical code to platform threads) |
| WebFlux / Reactor | A small, fixed pool of event-loop threads (Netty) handles many concurrent requests by never blocking any of them on I/O. | spring-boot-starter-webflux dependency | Reactive, functional (Mono / Flux) |
The first two produce the same code. The third asks for a different programming model in exchange for never occupying a thread while waiting — a trade this post measures rather than assumes.
Why this rewrite doesn’t reuse the old numbers, and how it measures instead
The version of this post it replaces benchmarked Boot 3.4.0 on JDK 21.0.3 with wrk against an assumed 8-core machine. This rewrite’s build environment has neither wrk nor 8 cores — it is a 2 vCPU sandbox. Rather than copy old numbers forward next to a changed version string, both companion repos build their own concurrent load generator: a java.net.http.HttpClient backed by a virtual-thread executor, used only as the client, firing N concurrent requests and measuring wall time, p50 and p99 from the responses that come back. The platform-thread and virtual-thread numbers below are reused, unchanged, from this blog’s own Boot 4.1 virtual-threads re-run rather than measured a second time — same hardware, same method, same day’s JDK. The WebFlux numbers are new, measured the same way against a separate Netty server — with one addition: a single trial at 600 concurrent requests swung by more than 50% between otherwise-identical back-to-back runs on this shared sandbox, so the WebFlux I/O and event-loop-starvation numbers below are the median of several independent trials, not one shot. That means the WebFlux numbers are measured more carefully than the single-trial platform/virtual-thread numbers they’re compared against — worth keeping in mind wherever this post reports a precise-looking gap between WebFlux and virtual threads specifically.
- Full methodology and the JIT-warmup bug it caught: virtual-threads-benchmark/docs/02-benchmark-methodology.md
- The WebFlux-side methodology, including the event-loop-starvation scenario introduced below: virtual-threads-benchmark-webflux/docs/01-webflux-benchmark-methodology.md
Endpoint 1: I/O-bound (300ms simulated downstream call)
This models the most common Spring Boot workload: a request that spends most of its time waiting on a database or a downstream HTTP call, simulated here with a fixed 300ms delay so all three models can be measured against the identical wait.
Platform threads and virtual threads (identical controller)
@RestController
public class DemoController {
@GetMapping("/io")
public String io() throws InterruptedException {
Thread.sleep(300);
return "io:" + Thread.currentThread();
}
}
(DemoController.java) The controller is identical for platform threads and virtual threads — the only difference is one property in application.yml. With platform threads, Thread.sleep(300) blocks an OS thread for the full 300ms. With virtual threads, the carrier is released during the sleep and reused for other requests.
WebFlux (Reactor)
@RestController
public class ReactiveDemoController {
@GetMapping("/io")
public Mono<String> io() {
return Mono.delay(Duration.ofMillis(300))
.map(tick -> "io:" + Thread.currentThread());
}
}
(ReactiveDemoController.java) Mono.delay() is the reactive equivalent of the same 300ms wait: it schedules a callback on the event loop and returns immediately, parking no thread. With a real R2DBC driver, the delay would be the actual database round-trip.
Results: I/O-bound, concurrency 600
platform threads : total=600 success=600 wall=1325ms p50=733ms p99=1201ms
virtual threads : total=600 success=600 wall=1042ms p50=658ms p99=720ms
webflux (netty) : total=600 success=600 wall=569ms p50=455ms p99=510ms
(01-io-bound-benchmark.txt, 01-io-bound-webflux.txt) 600 concurrent requests against Tomcat’s default 200-thread pool queue in roughly three sequential batches, each paying the full 300ms — which is why the platform-thread wall time lands near 3×300ms plus scheduling overhead. Virtual threads and WebFlux both avoid that queueing entirely, for the same underlying reason from different mechanisms: neither one occupies a worker while it waits. On this run WebFlux is ahead of virtual threads on all three metrics, not just wall time — but before reading that as a clean win, see the callout below: the WebFlux number is a median of five trials taken specifically because a single trial at this concurrency swung by more than 50% run to run on this sandbox, while the virtual-thread number is a single trial from a different post’s benchmark. The part that survives that caveat easily: platform threads are clearly worse than either alternative here, by a margin far wider than the observed run-to-run noise.
Why this number is a median of five trials, not one. While building this benchmark, an identical single trial of the WebFlux /io endpoint at 600 concurrency produced wall times from 795ms to 1247ms across otherwise-identical back-to-back runs — a swing bigger than the actual difference between any two of these three threading models. A single HTTP load-test run on a shared, noisy sandbox is not precise enough to support a fine-grained percentage claim; it only looks precise because it returns one number. Taking the median of five independent trials fixes that for the WebFlux side of this comparison, but the platform-thread and virtual-thread numbers above are still single trials, measured for a different post before this variance was known. Treat the exact size of the WebFlux-vs-virtual-threads gap as directional rather than reproducible to the point; treat “platform threads are clearly the slowest of the three” as solid, since it holds up even against the noisiest single WebFlux trial observed (1247ms still beats platform threads’ 1325ms).
- This box’s own measurement method, including why it doesn’t reuse the old post’s wrk numbers: virtual-threads-benchmark/docs/02-benchmark-methodology.md
- If your own service is I/O-bound with a blocking driver (JDBC, RestClient): virtual threads need only one property to get this same result without a reactive rewrite.
Endpoint 2: CPU-bound (20,000× SHA-256, no I/O)
This is the counterexample endpoint: a request that performs 20,000 SHA-256 hash iterations with no blocking at all. It exists to test a specific claim every virtual-threads article makes — that CPU-bound work gets no benefit from either virtual threads or WebFlux — and, for the WebFlux leg, to show a real trap in how easy it is to get this endpoint wrong.
private static final int HASH_ITERATIONS = 20_000;
@GetMapping("/cpu")
public String cpu() throws NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] data = "spring-boot-4.1-virtual-threads-benchmark".getBytes();
for (int i = 0; i < HASH_ITERATIONS; i++) {
data = md.digest(data);
}
return "cpu:" + Thread.currentThread() + ":" + data.length;
}
(DemoController.java) Platform threads and virtual threads run this exact code unmodified — a CPU-bound virtual thread never yields, so it stays mounted on its carrier for the whole computation and competes for the same physical cores a platform thread would.
If your own CPU-bound benchmark shows virtual threads winning decisively, check your warm-up before you check your thread model. An earlier, unwarmed cut of this exact benchmark showed virtual threads finishing 4× faster than platform threads — not physically possible for CPU-bound work. The cause:MessageDigest.digestand the dispatch path JIT-compile per class, not per Spring context, and the benchmark starts a freshApplicationContextper scenario in the same JVM; whichever scenario ran second inherited a free JIT head start. The fix, applied to every measurement in this post: an untimed warm-up round through the identical code path before every timed run. Full writeup: docs/02-benchmark-methodology.md.
The WebFlux version: naive, then correctly offloaded
WebFlux has no thread to block in the first place — until you write CPU work the obvious way and hand it to the framework anyway:
// Naive: no subscribeOn. Runs on whichever thread subscribes to the Mono --
// in a WebFlux handler, that's the Netty event-loop thread that received the request.
@GetMapping("/cpu")
public Mono<String> cpu() {
return Mono.fromCallable(CpuWork::run);
}
// Correct: offload to a scheduler sized for CPU work.
@GetMapping("/cpu-offloaded")
public Mono<String> cpuOffloaded() {
return Mono.fromCallable(CpuWork::run)
.subscribeOn(Schedulers.parallel());
}
(ReactiveDemoController.java, CpuWork.java — the identical 20,000× SHA-256 loop, so all three models pay an identical per-request cost) Mono.fromCallable() with no subscribeOn(...) is the single most common way a WebFlux handler ends up doing real work, and it runs synchronously on the event-loop thread that received the request — there is no I/O for the framework to hand off. Schedulers.parallel(), not Schedulers.boundedElastic(), is Reactor’s own recommended scheduler for CPU-bound work; boundedElastic exists for blocking I/O you can’t avoid and is sized far larger than the core count, which makes it the wrong tool here even though it’s the scheduler most WebFlux tutorials reach for first.
Results: CPU-bound, concurrency 60
platform threads : total=60 success=60 wall=208ms p50=130ms p99=202ms
virtual threads : total=60 success=60 wall=201ms p50=187ms p99=196ms
webflux naive (Mono.fromCallable, no subscribeOn) : total=60 success=60 wall=302ms p50=145ms p99=288ms
webflux offloaded (subscribeOn(Schedulers.parallel())) : total=60 success=60 wall=271ms p50=142ms p99=261ms
(02-cpu-bound-benchmark.txt, 02-cpu-bound-webflux.txt) Platform threads and virtual threads land within a few percent of each other, exactly as advertised for CPU-bound work — both ride the same 2 physical cores. The WebFlux numbers hold a real surprise.
Worth checking rather than assuming: offloaded beats naive here on every metric, consistent with the advice, but by a smaller margin than “never run CPU work on the event loop” might suggest (roughly 2–10% across wall/p50/p99) — and the reason is a fact about this specific JVM, not a flaw in the advice.LoopResources.DEFAULT_IO_WORKER_COUNT(Reactor Netty’s default event-loop thread count, printed directly rather than read off documentation) ismax(availableProcessors(), 4)— 4 threads on this 2-core sandbox — whileSchedulers.parallel()‘s default pool is sized toavailableProcessors()— 2 threads, confirmed the same way. The “incorrectly” naive endpoint has more worker threads available to it at this concurrency than the “correctly offloaded” one, which caps how much a per-endpoint comparison can show. The gap only becomes dramatic once you measure what naive does to other traffic sharing the event loop — see the next section, where the same modest single-digit difference here turns into unrelated/iotraffic degrading by more than double.
- The full JIT-warmup bug and fix, in context: virtual-threads-benchmark/docs/02-benchmark-methodology.md
- Why
Schedulers.parallel()and notboundedElastic()for CPU work, and the event-loop worker count measured directly: virtual-threads-benchmark-webflux/docs/01-webflux-benchmark-methodology.md
What breaks: the naive endpoint’s real cost is to everyone else
The CPU-bound benchmark above measured /cpu in isolation, and in isolation the naive version doesn’t look catastrophic. That is the trap. A WebFlux server has one small pool of event-loop threads shared by every endpoint, not one pool per endpoint — so the real question is not “how slow is /cpu by itself” but “what does running /cpu naively do to the /io traffic sharing the same event loop.”
/io alone (baseline, no concurrent CPU load) : total=20 success=20 wall=347ms p50=332ms p99=346ms
/io while 150x /cpu (naive) run concurrently : total=20 success=20 wall=995ms p50=742ms p99=950ms
/io while 150x /cpu-offloaded run concurrently : total=20 success=20 wall=731ms p50=693ms p99=697ms
(03-event-loop-starvation.txt, from WebfluxLoadBenchmarkTest.java, median of 3 trials each) 150 concurrent naive /cpu requests are fired first, and while they’re still in flight, 20 unrelated /io requests are fired at the same server. /io‘s own code never changed between the three rows — only what else the server happened to be doing did. Naive /io p99 nearly triples the undisturbed baseline (2.7×). The offloaded case also rises from baseline at this concurrency — a second finding worth stating plainly below — but naive is still consistently the worse of the two, by roughly 7–36% across wall/p50/p99.
Getting a reproducible number here took two fixes, not one. This test’s first cut used only 8 concurrent/cpurequests and the result was inconsistent across runs — sometimes naive looked worse, sometimes offloaded did. The reason: 8 requests drain through 4 event-loop threads in two short rounds, finishing well before the/iomeasurement window was even over, so most of that window ran with no concurrent CPU load at all. Raising the load to 60 concurrent requests (matching the CPU-bound benchmark above) fixed the under-loading problem, but still flaked once during verification — 372ms vs. 374ms p99, a real tie rather than a real result, the same single-trial noise problem documented for the I/O-bound benchmark above. The number reported here uses 150 concurrent requests and the median of 3 trials, and held up consistently across repeated verification runs. A second finding came out of pushing the load this high: at 150 concurrent CPU-bound requests — far beyond the 2 physical cores available — even the offloaded case now visibly degrades/io, because offloading moves the work off the event loop’s threads but not off the CPU itself, and there simply isn’t enough CPU to go around at this concurrency. Naive is still the clearly worse of the two, which is the mechanism this test exists to show — it just isn’t a guarantee that offloading means zero impact once concurrent CPU load exceeds what the box can run at once.
Auditing your own WebFlux service: grep for Mono.fromCallable, Flux.fromIterable wrapping computation, or any synchronous call inside a reactive chain with no subscribeOn(...) after it — each one runs on the event loop by default, and the endpoint it’s in will look fine in its own benchmark right up until it starts starving everything else on the same server.
- Full methodology for this scenario, including the under-loaded first cut and the fix: virtual-threads-benchmark-webflux/docs/01-webflux-benchmark-methodology.md
- Reactor Netty event-loop sizing: projectreactor.io/docs/netty
The combined picture
| Workload | Platform threads | Virtual threads | WebFlux | Winner |
|---|---|---|---|---|
| I/O-bound, 600 concurrent | wall=1325ms | wall=1042ms | wall=569ms (median of 5) | WebFlux by a wide margin on this run; both comfortably beat platform threads (see methodology note on measurement reliability) |
| CPU-bound, 60 concurrent (correctly offloaded) | wall=208ms | wall=201ms | wall=271ms | Platform ≈ virtual; WebFlux pays a real offload cost even done correctly |
| CPU-bound, done naively, measured in isolation | n/a | n/a | wall=302ms | A moderate, single-digit-to-teens gap versus offloaded by itself |
Same naive endpoint, measured by its effect on unrelated /io traffic (150 concurrent, median of 3) | n/a | n/a | /io p99 nearly triples the undisturbed baseline; offloaded also rises, but 7–36% less | Neither platform nor virtual threads have an equivalent failure mode for this endpoint shape |
Three things matter more than the rest of the table. First: for I/O-bound work, WebFlux is well ahead of virtual threads on this run on every metric — but that specific gap needs a caveat the platform-thread comparison doesn’t: the WebFlux number is the median of five trials, taken because a single trial swung by more than 50% run to run on this sandbox, while the virtual-thread number is a single trial measured for a different post. Read the WebFlux-vs-virtual-threads gap as directional, not precise. What doesn’t need the caveat: platform threads are clearly the slowest of the three, by a margin much wider than the observed noise. Second, and easy to miss if you only benchmark one endpoint at a time: a CPU-bound WebFlux endpoint’s own numbers can look like a moderate, tolerable cost right up until you measure what it does to everything else running on the same server. Third: even the fix for that (offloading to Schedulers.parallel()) isn’t a complete guarantee once concurrent CPU load is heavy enough to saturate the physical cores themselves, not just the event loop’s thread pool.
How this actually works underneath
- Platform thread blocking —
Thread.sleep(300)on a platform thread parks that OS thread in a wait queue for the duration. It still occupies ~1MB of native stack and a pool slot. With 600 concurrent requests and a 200-thread pool, 400 requests queue, which is what inflates the platform-thread p99. - Virtual thread unmounting — the JVM intercepts
Thread.sleep()and every blocking I/O primitive, unmounting the virtual thread from its carrier and pushing its stack to the heap. The carrier is immediately reused. On remount, the virtual thread may land on a different carrier. - Reactor event loop — WebFlux’s Netty server runs a fixed pool of event-loop threads.
Mono.delay()schedules a callback on the Reactor scheduler; no thread parks at all, and the event-loop thread returns to the selector loop immediately to service other connections. - CPU-bound parity, mostly — for the hash endpoint, no blocking occurs, so virtual threads and platform threads both ride the same physical cores and land close together. WebFlux’s naive path also runs on the event loop unless explicitly offloaded — and, as measured above, the event loop’s default thread count and
Schedulers.parallel()‘s default thread count are not the same number, which is worth confirming on your own hardware rather than assuming. - WebFlux + blocking JDBC trap — unchanged advice: if you use WebFlux with a blocking JDBC driver instead of R2DBC, every DB call must be wrapped in
Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic())— the correct scheduler there, since it’s blocking I/O, not CPU work. Forgetting this parks an event-loop thread and collapses throughput to worse than platform threads. Virtual threads avoid this trap entirely: you call JDBC normally.
Where reactive (WebFlux) still wins
Even setting aside the throughput numbers above and the measurement-reliability caveat attached to them, there are two specific situations where you should not migrate away from WebFlux regardless of what any benchmark says.
Streaming pipelines with backpressure
If your service streams large results — paginated cursors, SSE feeds, file downloads — Flux gives you producer–consumer backpressure for free: the downstream subscriber controls how many items are pulled, and the upstream never emits more than that. Building the same pipeline on virtual threads with a blocking InputStream is possible, but backpressure then needs manual queue management. The reactive model is structurally correct here, independent of the throughput numbers above — but it’s worth verifying rather than assuming, because the obvious way to build the example nearly failed while writing this section.
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamResults() {
return Flux.range(0, 50)
.map(i -> "event-" + i);
}
(ReactiveDemoController.java) This is built on Flux.range(), not the more “realistic-looking” Flux.interval() a streaming example would normally reach for — on purpose. The first draft of this endpoint used Flux.interval(Duration.ofMillis(100)).take(1_000), and the test written to prove its backpressure failed immediately with OverflowException: Could not emit tick 3 due to lack of requests: interval() ticks on its own wall-clock schedule independent of downstream demand, and a subscriber that requests slower than the tick rate makes it throw rather than wait. That failure is itself the finding — interval() is not a safe way to demonstrate backpressure, because a slow-enough subscriber can force it into an error state, the opposite of the point. range() has no independent production schedule, so it can never outrun demand:
StepVerifier.create(controller.streamResults(), 3)
.expectNext("event-0", "event-1", "event-2")
.expectNoEvent(Duration.ofMillis(80)) -- no 4th item arrives without a request
.thenRequest(2).expectNext("event-3", "event-4")
.thenRequest(45).expectNextCount(45)
.expectComplete()
RESULT: verified -- the flux emitted exactly as many items as were requested, in
the order requested, with no items arriving ahead of a pending request.
(04-stream-backpressure.txt, from StreamBackpressureTest.java) The subscriber, not the producer, controls the emission rate — verified, not asserted.
A fully async stack (R2DBC + WebClient)
When every layer — database driver, downstream HTTP client, messaging — is non-blocking, WebFlux’s event loop runs with zero thread-parking overhead across the entire request lifecycle. Virtual threads still allocate a heap object per request and pay JVM scheduler dispatch on every mount/unmount cycle; at extreme concurrency (tens of thousands of simultaneous requests) that overhead becomes measurable. If you are already on a fully reactive stack, staying there is the right call — this post’s own I/O-bound numbers show WebFlux ahead of virtual threads on every metric on this run, though see the methodology note above before treating the exact size of that gap as settled.
- Project Reactor’s own backpressure documentation: projectreactor.io/docs/core
Where virtual threads are the wrong choice
| Scenario | Why virtual threads don’t help | Use instead |
|---|---|---|
| CPU-intensive tasks (hashing, image processing, ML inference) | No extra cores are created. Carrier pool = CPU count, same as a fixed platform pool. | ForkJoinPool sized to availableProcessors() |
| Blocking JDBC in a WebFlux service | Parks an event-loop thread; throughput collapses, as shown above. | R2DBC, or .subscribeOn(Schedulers.boundedElastic()) |
Fat ThreadLocal values at millions of requests | Each virtual thread carries its own ThreadLocal map — heap bloat at scale. | ScopedValue (finalized in JDK 25 via JEP 506) |
| Native / JNI blocking calls | Native frames still pin the carrier until the native call returns — the one pinning case JEP 491 did not remove. | Isolate in a bounded platform-thread pool |
| Fully reactive stack already in production | Migration cost buys an uncertain throughput change — this post’s own numbers favor WebFlux, but with a measurement-reliability caveat attached — against a real rewrite. | Stay on WebFlux |
One entry from the pre-2025 version of this advice needs an explicit correction: a virtual thread that enters a plain synchronized block and blocks inside it no longer pins its carrier, as of JDK 24. JEP 491, “Synchronize Virtual Threads without Pinning,” shipped GA in JDK 24 and removed that pinning for ordinary synchronized methods and blocks and for Object.wait(). This repo runs JDK 25, so it inherits that fix, and this blog’s own dedicated post on the subject proves it directly: the identical class file run on JDK 21.0.12.1 and JDK 25.0.4.1 goes from roughly 4.8 seconds to 301ms for the same synchronized-then-sleep pattern. If your JDBC driver’s documentation still warns about synchronized-based pinning written before it was tested against JDK 24, verify against your actual JDK rather than trusting the warning as written — the remaining pinning trigger is narrower than “avoid all synchronized“: a virtual thread calling native code that itself calls back into blocking or synchronizing Java code, per the JEP’s own text.
The decision framework
Copy this into your architecture decision record. The four rows cover the great majority of real Spring Boot service shapes.
| If your service… | Pick | Because |
|---|---|---|
| …is I/O-bound and uses blocking drivers (JDBC, RestClient), and you value simple, debuggable code | Virtual Threads | Dramatically better than platform threads with one property, no rewrite — and no reactive rewrite risk. Stack traces, thread dumps and ThreadLocals all keep working; WebFlux may still lead on raw I/O-bound throughput, but not by enough to justify a rewrite of a working blocking codebase on its own. |
| …is I/O-bound and already uses non-blocking drivers (R2DBC, WebClient), or needs streaming with backpressure | WebFlux | Your stack already eliminates blocking; WebFlux’s throughput edge on this post’s own numbers compounds with a fully non-blocking pipeline, and backpressure is a first-class primitive regardless of the exact throughput gap. |
| …is CPU-bound (hashing, compression, image processing, ML inference) | Platform Threads with a ForkJoinPool | Bounded by cores, not threads. Both other models tie here at best; WebFlux additionally risks the naive-endpoint trap measured above if the offload is forgotten. |
| …is a WebFlux service that does real CPU work in a handler today | Audit for un-offloaded Mono.fromCallable/Flux.fromIterable, then subscribeOn(Schedulers.parallel()) | Not boundedElastic() — that scheduler is sized for blocking I/O, not CPU work, and using it doesn’t fix the wrong problem. |
Is your workload primarily I/O-bound?
+-- YES
| +-- Is your full stack non-blocking (R2DBC, WebClient)?
| | +-- YES -> WebFlux (you're already there; no migration needed)
| | +-- NO -> Virtual Threads (one config line, ~10-65% over platform threads here depending on the metric)
| +-- Do you need streaming / backpressure?
| +-- YES -> WebFlux (Flux backpressure is structural)
| +-- NO -> Virtual Threads
+-- NO (CPU-bound)
+-- Platform Threads with ForkJoinPool(availableProcessors())
(Virtual threads tie here; WebFlux ties too ONLY if the CPU work is offloaded
to Schedulers.parallel() -- verify the offload, don't assume it)
Enabling each model on Spring Boot 4.1.1
# Virtual threads -- one property, unchanged since Boot 3.2
spring:
threads:
virtual:
enabled: true
# Recommended alongside it: tune HikariCP independently of thread count.
# Virtual threads let you have thousands of concurrent requests, but your
# DB connection pool is still the real bottleneck.
spring:
datasource:
hikari:
maximum-pool-size: 50
minimum-idle: 10
<!-- WebFlux: swap the web starter, don't add both to the same module -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
(virtual-threads-benchmark-webflux/pom.xml) This repo keeps platform/virtual threads and WebFlux in two separate Maven modules rather than one module with both starters, for exactly the reason the FAQ below spells out: mixing them on one classpath fights over how Spring Boot decides which embedded server to start.
AI prompts you can use
Audit a WebFlux service for un-offloaded CPU work
What it does: Scans a WebFlux codebase for the exact trap this post measures — synchronous or CPU-bound work inside a reactive chain with no subscribeOn(...), or offloaded to the wrong scheduler.
When to use it: Before you trust a per-endpoint benchmark that looks fine in isolation.
Audit this Spring WebFlux codebase for CPU work running on the event loop. Identify:
(1) Mono.fromCallable / Flux.fromIterable / any synchronous call inside a reactive chain
with no subscribeOn(...) after it, (2) any subscribeOn(Schedulers.boundedElastic()) used
for genuinely CPU-bound work (wrong scheduler -- boundedElastic is for blocking I/O),
(3) blocking JDBC calls not wrapped in subscribeOn at all. For each finding, explain what
it does to OTHER endpoints sharing the same event loop under load, not just its own
latency.
Decide the right threading model for one endpoint
What it does: Classifies a controller method’s workload and recommends platform threads, virtual threads, or WebFlux with the correct scheduler, based on where it actually spends time.
When to use it: Per-endpoint, rather than picking one model for an entire service.
Here is my controller method: [paste here]. Classify its workload as I/O-bound,
CPU-bound, or mixed. If I/O-bound, recommend virtual threads (blocking driver) or WebFlux
(non-blocking driver already in use). If CPU-bound, recommend a platform-thread
ForkJoinPool, or, if this is a WebFlux service, Schedulers.parallel() -- not
boundedElastic() -- and flag whether the current code already does this correctly.
Frequently asked questions
Should I migrate my existing WebFlux service to virtual threads?
Only if your stack is not fully non-blocking. If you’re on JDBC rather than R2DBC, WebFlux forces Schedulers.boundedElastic() around every DB call — structurally awkward and easy to forget. Virtual threads let you write plain blocking JDBC and, per the I/O-bound numbers above, still comfortably beat platform threads with none of the rewrite — WebFlux may hold a real throughput edge on this run, but not one large enough to justify rewriting a working blocking codebase for it alone. If your stack is already R2DBC + WebClient and performing well, migration buys a possible regression and real refactoring risk. Stay put.
My naive vs. offloaded CPU-bound benchmark only shows a moderate gap — does that mean offloading doesn’t matter much?
No — that’s exactly the trap this post’s own first-cut measurement fell into. Benchmarking the naive endpoint alone can look like a tolerable, moderate cost on a small or lightly loaded box, because the event loop may still have spare threads at that concurrency. The real cost shows up in other traffic sharing the same event loop, measured directly in the event-loop-starvation section above, where the same naive endpoint nearly triples unrelated /io p99 and is still meaningfully worse than the offloaded case even once concurrent CPU load is heavy enough to strain the physical cores too. Always test a CPU-bound endpoint under concurrent unrelated load, not in isolation — and make sure the concurrent load is heavy enough, and lasts long enough, to actually saturate the event loop for the whole measurement window, and that you’re looking at more than one trial; this post’s own first attempts at that test used too little load, then flaked on a single-trial tie, before landing on 150 concurrent requests and a median of 3 trials.
Can I put spring-boot-starter-web and spring-boot-starter-webflux in the same module?
Technically the dependencies can coexist, but WebApplicationType.deduceFromClasspath() then has to guess which embedded server you want, and the two starters were never designed to run together in one application. This repo keeps them in separate Maven modules for exactly that reason — simpler than fighting the auto-configuration, and it’s what let this post’s platform/virtual and WebFlux benchmarks each run against a clean, single-purpose server.
Does JEP 491 (JDK 24) change anything about the WebFlux side of this comparison?
No — JEP 491 is specifically about virtual threads no longer pinning their carrier on a plain synchronized block. WebFlux’s event loop was never affected by carrier pinning in the first place; its equivalent failure mode is the event-loop starvation measured above, which is a scheduling problem, not a pinning one.
Does HikariCP work with virtual threads?
Yes, as of HikariCP 5.1.0. Virtual threads don’t remove the need for connection pooling — 10,000 virtual threads wanting a DB connection simultaneously queue at HikariCP’s pool, not at an OS thread limit. Size the pool to your database’s capacity, independent of thread model.
Conclusion
Re-run for real on Boot 4.1.1 and JDK 25, part of the original claim holds and part of it needed correcting. What holds: for I/O-bound work, virtual threads and WebFlux both comfortably beat platform threads, by a wide and reliable margin. What needed correcting: this rewrite’s own measurements initially showed WebFlux and virtual threads in a close, mixed race — until building the benchmark further exposed that single HTTP load-test trials on this shared sandbox swing by more than 50%, large enough to flip which model looks ahead. Once that was fixed with repeated trials and medians, WebFlux came out clearly ahead of virtual threads on I/O-bound throughput on this run — reported honestly, with the caveat that the virtual-thread number it’s compared against is still a single trial from a different post. What’s new in this rewrite for the CPU-bound story: the naive Mono.fromCallable() pattern doesn’t look obviously broken in its own benchmark on a small box, the actual damage only shows up once you measure what it does to unrelated traffic sharing the same event loop, and even the “correct” fix of offloading to Schedulers.parallel() stops being a full guarantee once concurrent CPU load is heavy enough to saturate the physical cores themselves. If your service is I/O-bound and already blocking, spring.threads.virtual.enabled=true remains the highest-leverage one-line change available regardless of exactly how it compares to WebFlux. If you’re fully reactive and it’s working, stay there. And if you’re running any CPU work inside a WebFlux handler, the one thing worth checking today is whether it’s actually reaching Schedulers.parallel() — not just whether its own benchmark looks fine, and not assuming offloading alone is a complete fix under enough concurrent load.
Further reading
- virtual-threads-benchmark — companion repo, platform vs virtual threads
- virtual-threads-benchmark-webflux — companion repo, the WebFlux leg and the event-loop-starvation proof
- Virtual Threads on Spring Boot 4.1: The Benchmarks, Re-Run, and the Pinning Advice That Expired
- @Async in Spring Boot 4: Executors, Virtual Threads and the Self-Invocation Trap
- JEP 444: Virtual Threads
- JEP 491: Synchronize Virtual Threads without Pinning
- Project Reactor Reference Documentation
- Reactor Netty: Event Loop Workers
- HikariCP: About Pool Sizing
No Comments yet!