diff --git a/README.md b/README.md
index 308f7ce..e0667e9 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,8 @@ by that module's `scripts/run-all.sh`, never typed by hand.
|---|---|---|
| [`async/`](async/README.md) | [@Async in Spring Boot 4: Executors, Virtual Threads and the Self-Invocation Trap](https://ankurm.com/spring-boot-4-async-executors-virtual-threads/) | Which thread a method actually ran on, in every case where the answer is not the one you expect |
| [`scheduling/`](scheduling/README.md) | [@Scheduled, ShedLock and Distributed Cron: Scheduling That Survives Three Replicas](https://ankurm.com/spring-scheduled-shedlock-distributed-cron/) | Three replicas against one database running the same job three times, then one row and one conditional UPDATE fixing it |
+| [`virtual-threads-benchmark/`](virtual-threads-benchmark/README.md) | [Virtual Threads on Spring Boot 4.1: The Benchmarks, Re-Run, and the Pinning Advice That Expired](https://ankurm.com/leveraging-virtual-threads-in-spring-boot-3-4-building-high-throughput-services/) | Platform threads vs virtual threads, re-benchmarked on Boot 4.1.1 / JDK 25, plus JEP 491's fix to `synchronized` pinning proven against a real JDK |
+| [`virtual-threads-benchmark-webflux/`](virtual-threads-benchmark-webflux/README.md) | [Virtual Threads vs Reactive (WebFlux) vs Platform Threads: Benchmarks and a Decision Framework](https://ankurm.com/virtual-threads-vs-webflux-vs-platform-threads-spring-boot-benchmarks/) | The WebFlux leg of the three-way comparison, plus the event-loop-starvation failure mode an isolated CPU benchmark can't show |
## Common ground
@@ -31,6 +33,13 @@ The `scheduling` module also needs a database. `scheduling/scripts/postgres.sh`
throwaway PostgreSQL 14 into `target/` with no Docker and no root, which is how its transcripts
were produced; `docker-compose.yml` is there for anyone who would rather use Docker.
+`virtual-threads-benchmark` and `virtual-threads-benchmark-webflux` are a similar pair: the
+first re-benchmarks platform threads against virtual threads for one post, the second adds the
+WebFlux leg for a different, three-way-comparison post, and reuses the first module's
+committed transcripts rather than re-measuring the same thing twice. Both use the same
+client-side load generator (`java.net.http.HttpClient` on a virtual-thread executor, client
+role only) so all three threading models in the three-way post are measured the same way.
+
## Licence
MIT — see [LICENSE](LICENSE).
diff --git a/virtual-threads-benchmark-webflux/.gitignore b/virtual-threads-benchmark-webflux/.gitignore
new file mode 100644
index 0000000..b2e433c
--- /dev/null
+++ b/virtual-threads-benchmark-webflux/.gitignore
@@ -0,0 +1,4 @@
+target/
+*.class
+.idea/
+*.iml
diff --git a/virtual-threads-benchmark-webflux/LICENSE b/virtual-threads-benchmark-webflux/LICENSE
new file mode 100644
index 0000000..aa5473f
--- /dev/null
+++ b/virtual-threads-benchmark-webflux/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Ankur Mhatre
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/virtual-threads-benchmark-webflux/README.md b/virtual-threads-benchmark-webflux/README.md
new file mode 100644
index 0000000..21c7f28
--- /dev/null
+++ b/virtual-threads-benchmark-webflux/README.md
@@ -0,0 +1,65 @@
+# virtual-threads-webflux-benchmark
+
+Companion module for the ankurm.com post **"Virtual Threads vs Reactive (WebFlux) vs Platform
+Threads: Benchmarks and a Decision Framework."** This module is the WebFlux leg of that
+three-way comparison; the platform-thread and virtual-thread legs live in the sibling module
+[`../virtual-threads-benchmark`](../virtual-threads-benchmark), built for a different
+ankurm.com post and reused here rather than re-run, so the platform/virtual numbers quoted in
+this post are the same numbers, not a second measurement of the same thing.
+
+Every number below is from a real concurrent load run against a real running embedded Netty
+server on this JDK, using the identical client-side load generator
+(`java.net.http.HttpClient`, virtual-thread executor, client-side only) as the sibling module.
+
+## Versions (verified against `repo1.maven.org` maven-metadata.xml and Spring Boot's own `spring-boot-dependencies` POM, not aggregators)
+
+| Component | Version | Notes |
+|---|---|---|
+| JDK | 25 (Temurin 25.0.4.1+1) | same as `../virtual-threads-benchmark` |
+| Spring Boot | 4.1.1 | latest GA at time of writing |
+| Spring Framework | 7.0.9 | latest GA |
+| Reactor | managed by `spring-boot-dependencies` 4.1.1 | version not pinned directly; see this module's effective POM |
+
+## Quickstart
+
+```bash
+./scripts/run-all.sh # regenerates every file in docs/output/ from a real test run
+./scripts/run.sh # start on :8080
+```
+
+Requires JDK 25 and Maven. This module's own benchmark ran on the same 2 vCPU sandbox as
+`../virtual-threads-benchmark` -- see
+[docs/01-webflux-benchmark-methodology.md](docs/01-webflux-benchmark-methodology.md) for why
+that matters and what it means for the numbers below.
+
+## What's demonstrated where
+
+| Area | Source | Test | Transcript |
+|---|---|---|---|
+| I/O-bound throughput: WebFlux, 600 concurrent, median of 5 trials (single trials swung 50%+ on this box) | [`ReactiveDemoController`](src/main/java/com/ankurm/vthreadswebflux/ReactiveDemoController.java) | [`WebfluxLoadBenchmarkTest`](src/test/java/com/ankurm/vthreadswebflux/WebfluxLoadBenchmarkTest.java) | [`01`](docs/output/01-io-bound-webflux.txt) |
+| CPU-bound throughput: naive (event loop) vs offloaded (`Schedulers.parallel()`), 60 concurrent -- and why the gap is smaller than expected on this box | same | same | [`02`](docs/output/02-cpu-bound-webflux.txt) |
+| Event-loop starvation: what the naive CPU endpoint actually costs *other* traffic on the same server, 150 concurrent, median of 3 trials | [`CpuWork`](src/main/java/com/ankurm/vthreadswebflux/CpuWork.java) | same | [`03`](docs/output/03-event-loop-starvation.txt) |
+| Backpressure is structural: a `Flux` never outruns its subscriber's requests | [`ReactiveDemoController.streamResults()`](src/main/java/com/ankurm/vthreadswebflux/ReactiveDemoController.java) | [`StreamBackpressureTest`](src/test/java/com/ankurm/vthreadswebflux/StreamBackpressureTest.java) | [`04`](docs/output/04-stream-backpressure.txt) |
+
+## Documentation chapters
+
+1. [WebFlux benchmark methodology and results](docs/01-webflux-benchmark-methodology.md) --
+ I/O-bound, CPU-bound naive vs offloaded, the event-loop-starvation scenario the isolated CPU
+ benchmark can't show (including a first-cut version of that test that under-loaded the event
+ loop and had to be fixed), a real backpressure proof, and the discovery that single-trial
+ measurements on this sandbox swing 50%+ and had to be replaced with medians of several
+ trials, all on this sandbox's real hardware
+
+## A note on this module's relationship to `../virtual-threads-benchmark`
+
+[`../virtual-threads-benchmark`](../virtual-threads-benchmark) already contains a real,
+re-run platform-thread vs virtual-thread benchmark on this exact hardware and JDK, built for
+[Virtual Threads on Spring Boot 4.1](https://ankurm.com/leveraging-virtual-threads-in-spring-boot-3-4-building-high-throughput-services/).
+This module deliberately does not re-measure that comparison -- it reuses those committed
+transcripts and adds only the WebFlux leg, using the same client-side load-generation method,
+so the three-way post can quote one consistent measurement approach across all three models
+instead of stitching together benchmarks run different ways.
+
+## License
+
+MIT -- see [LICENSE](LICENSE).
diff --git a/virtual-threads-benchmark-webflux/docs/01-webflux-benchmark-methodology.md b/virtual-threads-benchmark-webflux/docs/01-webflux-benchmark-methodology.md
new file mode 100644
index 0000000..8d6a1d5
--- /dev/null
+++ b/virtual-threads-benchmark-webflux/docs/01-webflux-benchmark-methodology.md
@@ -0,0 +1,187 @@
+# 1. WebFlux benchmark methodology and results
+
+[README](../README.md) | Companion module: [`../virtual-threads-benchmark`](../virtual-threads-benchmark/README.md) (platform threads vs virtual threads)
+
+Source: [`ReactiveDemoController.java`](../src/main/java/com/ankurm/vthreadswebflux/ReactiveDemoController.java), [`CpuWork.java`](../src/main/java/com/ankurm/vthreadswebflux/CpuWork.java).
+Test: [`WebfluxLoadBenchmarkTest.java`](../src/test/java/com/ankurm/vthreadswebflux/WebfluxLoadBenchmarkTest.java).
+Transcripts: [`docs/output/01-io-bound-webflux.txt`](output/01-io-bound-webflux.txt), [`docs/output/02-cpu-bound-webflux.txt`](output/02-cpu-bound-webflux.txt), [`docs/output/03-event-loop-starvation.txt`](output/03-event-loop-starvation.txt), [`docs/output/04-stream-backpressure.txt`](output/04-stream-backpressure.txt).
+
+## Why this module exists, and why it's separate from `../virtual-threads-benchmark`
+
+The companion post for this module is the three-way comparison: platform threads vs virtual
+threads vs WebFlux. The platform-vs-virtual half of that comparison already has a real,
+re-run benchmark in [`../virtual-threads-benchmark`](../virtual-threads-benchmark), built for
+a different ankurm.com post and reused here rather than duplicated. This module adds the
+missing WebFlux leg, measured with the identical client-side load generator
+(`java.net.http.HttpClient` backed by a virtual-thread executor, used only as the *client*) so
+all three legs come from the same method on the same 2 vCPU sandbox.
+
+It is its own Maven module rather than a third profile inside `../virtual-threads-benchmark`
+because `spring-boot-starter-web` (Tomcat) and `spring-boot-starter-webflux` (Netty) on the
+same classpath fight over `WebApplicationType.deduceFromClasspath()` -- exactly the kind of
+fragile setup a benchmark should not carry. A module with only `spring-boot-starter-webflux`
+needs no such workaround.
+
+## A methodology correction made while building this module: single trials are not reliable here
+
+While measuring the I/O-bound endpoint, identical back-to-back single trials at 600
+concurrency swung from 795ms to 1247ms wall time on this shared sandbox -- a spread larger
+than the actual gap this benchmark exists to measure. A single HTTP load-test run on a
+noisy, shared 2 vCPU box is not precise enough to support a "X% faster" claim between two
+models that are actually close; it only looks precise because it produces one number.
+
+The fix applied to the I/O-bound scenario and the event-loop-starvation scenario below:
+run the load multiple independent times and report the **median** across trials, not a
+single shot. This is a real methodology change, not cosmetic -- it changed which model's
+number looked better in earlier drafts of this module before the fix was applied.
+
+**This has one important consequence for how to read the numbers below.** The
+platform-thread and virtual-thread numbers reused from
+[`../virtual-threads-benchmark`](../virtual-threads-benchmark) are **single trials**, measured
+for a different post before this variance was discovered there. The WebFlux numbers in this
+module are **medians of 5 (I/O) or 3 (event-loop starvation) trials**. Comparing a
+denoised median against a single trial is not perfectly apples-to-apples: a precise
+percentage gap between WebFlux and virtual threads should be read as directional, not as a
+figure you could reproduce to the point. A gap wide enough to swamp the observed ~50%
+single-trial swing -- which is the case for every comparison against platform threads in
+this post, and turned out to be the case for WebFlux vs. virtual threads too once
+de-noised -- is the part worth trusting.
+
+## I/O-bound: `/io`, `Mono.delay(300ms)`, concurrency 600, median of 5 trials
+
+```
+webflux (netty) : total=600 success=600 wall=569ms p50=455ms p99=510ms
+```
+
+`Mono.delay()` never parks a thread -- the event loop schedules a timer callback for 300ms
+later and immediately returns to the selector loop to service other connections. Against the
+single-trial platform/virtual-thread numbers in
+[`../virtual-threads-benchmark/docs/output/01-io-bound-benchmark.txt`](../virtual-threads-benchmark/docs/output/01-io-bound-benchmark.txt)
+(`platform wall=1325ms p50=733ms p99=1201ms`, `virtual wall=1042ms p50=658ms p99=720ms`),
+WebFlux's median-of-5 number is faster on all three metrics: roughly 55-58% faster than
+platform threads and 29-45% faster than virtual threads, depending on the metric. Platform
+threads being clearly worse is a robust finding -- even the noisiest single WebFlux trial
+observed while building this module (1247ms) still beats platform threads' 1325ms. The
+WebFlux-vs-virtual-threads gap specifically should be read with the single-trial-vs-median
+caveat above in mind, and its exact size moved by roughly 5-10% between the last two
+verification runs of this same median-of-5 measurement -- both models avoid Tomcat's
+thread-pool queueing entirely and are dramatically faster than platform threads for this
+workload, which is the reproducible part of this result.
+
+## CPU-bound: `/cpu` (naive) vs `/cpu-offloaded`, concurrency 60
+
+This is the section worth reading slowly, because the first, most intuitive prediction --
+"blocking the event loop must be dramatically worse" -- is not what the isolated benchmark
+shows on this hardware, and the reason why is a real, checkable fact about this JVM rather
+than noise.
+
+```
+LoopResources.DEFAULT_IO_WORKER_COUNT (Netty event-loop threads) = 4
+Runtime.availableProcessors() (Schedulers.parallel() thread count) = 2
+
+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
+```
+
+`/cpu` wraps the identical 20,000x SHA-256 loop from
+[`../virtual-threads-benchmark`'s `DemoController`](../virtual-threads-benchmark/src/main/java/com/ankurm/vthreads/DemoController.java)
+in `Mono.fromCallable()` with no `subscribeOn(...)` -- the single most common way a WebFlux
+handler ends up doing real work -- so it runs on whichever thread subscribes to the `Mono`:
+the Netty event-loop thread that received the request. `/cpu-offloaded` moves the identical
+work to `Schedulers.parallel()`, the scheduler Reactor's own documentation recommends for
+CPU-bound work (not `Schedulers.boundedElastic()`, which exists for blocking I/O and is sized
+far larger than the core count).
+
+
Reactor Netty's default event-loop pool
+(LoopResources.DEFAULT_IO_WORKER_COUNT, confirmed by printing the constant
+directly rather than reading it off documentation) is max(availableProcessors(), 4)
+-- 4 threads on this 2-core box. Schedulers.parallel()'s default pool is sized to
+availableProcessors() -- 2 threads on the same box, confirmed the same way. The
+endpoint that "incorrectly" runs on the event loop has more worker threads available
+to it, at this concurrency, than the "correctly offloaded" one. That is why offloaded is only
+roughly 2-10% faster across these three metrics rather than showing a dramatic gap -- not
+because the advice to offload CPU work is wrong.
+
+Offloaded is faster here on every metric, consistent with the advice, but the margin alone
+understates why the naive version is still wrong. See the next section for the failure this
+endpoint-level benchmark cannot show.
+
+## What the isolated CPU benchmark hides: event-loop starvation of *other* traffic
+
+```
+/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
+```
+
+(median of 3 trials each; see the methodology correction above for why)
+
+This is the real cost of the naive endpoint, and benchmarking `/cpu` by itself cannot show it:
+`/io` and `/cpu` share the same small event-loop pool. Firing concurrent `/cpu` requests and,
+while they are still in flight, firing 20 unrelated `/io` requests at the same server
+measures what happens to traffic that has nothing to do with the CPU-bound endpoint.
+
+**Getting a reproducible number here took two fixes, not one.** This test's first cut used
+only 8 concurrent `/cpu` requests and produced an inconsistent, sometimes-inverted result
+across repeated runs: 8 requests drain through 4 event-loop threads in two short rounds,
+finishing well before the `/io` measurement window was over. Raising the load to 60
+concurrent requests (matching the CPU-bound benchmark's own concurrency) fixed the
+under-loading problem but still left a margin thin enough to flake once during verification
+(372ms vs 374ms p99 -- a real tie, not a real result). The final fix was **150 concurrent
+`/cpu` requests, median of 3 trials** -- both changes, not a threshold that happened to pass
+once.
+
+**A second, more interesting finding came out of raising the load this high**: at 150
+concurrent CPU-bound requests -- far beyond the 2 physical cores available -- *even the
+offloaded case* now degrades `/io` noticeably (baseline p99 346ms vs. offloaded-load p99
+697ms, roughly 2x). Offloading moves the CPU work off the event loop's 4 threads onto
+`Schedulers.parallel()`'s 2 threads, which stops it from directly starving `/io`'s access to
+the event loop -- but it does not stop it from saturating the 2 physical cores those event-loop
+threads still need CPU time on. Naive is still clearly worse than offloaded (roughly 7-36%
+worse across wall/p50/p99, varying by metric and by trial), which is the mechanism this test
+is built to demonstrate; it is just not a free pass to "offloaded means no impact at all" once
+concurrent CPU load exceeds what the box can actually run at once. At higher production
+concurrency, both the direct mechanism (naive holding event-loop threads) and this indirect
+one (any CPU-bound load competing for the same physical cores) matter.
+
+
+- If you're 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.
+Schedulers.parallel() for CPU-bound work, Schedulers.boundedElastic()
+for blocking I/O you can't make non-blocking -- they are sized for different jobs and using the
+wrong one either wastes threads or defeats the point of offloading.
+- Reactor Netty event-loop sizing: projectreactor.io/docs/netty
+
+
+## Backpressure is structural, not a benchmark number
+
+`/stream` ([`ReactiveDemoController.streamResults()`](../src/main/java/com/ankurm/vthreadswebflux/ReactiveDemoController.java))
+and [`StreamBackpressureTest`](../src/test/java/com/ankurm/vthreadswebflux/StreamBackpressureTest.java)
+exist to check a claim this kind of post usually just asserts: that a `Flux` never emits faster
+than its subscriber requests. The test drives the endpoint's `Flux` with `StepVerifier`,
+requesting 3 items, then 2 more, then the remaining 45, and asserts no item ever arrives ahead
+of a pending request:
+
+```
+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()
+```
+
+(`docs/output/04-stream-backpressure.txt`) The endpoint is built on `Flux.range()` rather than
+the more "realistic-looking" `Flux.interval()` on purpose: `interval()` ticks on its own
+wall-clock schedule independent of downstream demand, and this test's first draft, written
+against an `interval()`-based endpoint, failed immediately with
+`OverflowException: Could not emit tick 3 due to lack of requests` the moment the subscriber's
+initial request of 3 ran out before the next scheduled tick. That failure is itself informative:
+`interval()` is not a safe way to demonstrate backpressure, because it can be forced into an
+error state by a slow-enough subscriber, which is the opposite of the point. `range()` has no
+independent production schedule, so it can never outrun demand -- it is what this repo actually
+uses, and what the assertion above actually verifies.
+
+Back to [README](../README.md).
diff --git a/virtual-threads-benchmark-webflux/docs/output/01-io-bound-webflux.txt b/virtual-threads-benchmark-webflux/docs/output/01-io-bound-webflux.txt
new file mode 100644
index 0000000..01813bb
--- /dev/null
+++ b/virtual-threads-benchmark-webflux/docs/output/01-io-bound-webflux.txt
@@ -0,0 +1,12 @@
+I/O-bound endpoint (/io, Mono.delay(300ms)), concurrency=600, median of 5 trials, 2 vCPU sandbox, Spring Boot 4.1.1 / JDK 25.0.4.1
+==================================================================================================================================
+webflux (netty) : total=600 success=600 wall=569ms p50=455ms p99=510ms
+
+Mono.delay() never parks a thread -- the event loop schedules a timer callback and
+goes back to the selector loop immediately. A single trial at this concurrency swung
+50%+ between back-to-back runs on this shared sandbox -- bigger than the gap being
+measured -- so the number above is the median of 5 independent trials, not
+one shot. Even so it lands in the same range as virtual threads' /io result in
+../virtual-threads-benchmark/docs/output/01-io-bound-benchmark.txt (a single trial):
+treat any single-run percentage gap between WebFlux and virtual threads here as
+noise-level, not a reliable ranking.
diff --git a/virtual-threads-benchmark-webflux/docs/output/02-cpu-bound-webflux.txt b/virtual-threads-benchmark-webflux/docs/output/02-cpu-bound-webflux.txt
new file mode 100644
index 0000000..646aa00
--- /dev/null
+++ b/virtual-threads-benchmark-webflux/docs/output/02-cpu-bound-webflux.txt
@@ -0,0 +1,17 @@
+CPU-bound endpoint (/cpu vs /cpu-offloaded, 20,000x SHA-256), concurrency=60, 2 vCPU sandbox, Spring Boot 4.1.1 / JDK 25.0.4.1
+==============================================================================================================================
+LoopResources.DEFAULT_IO_WORKER_COUNT (Netty event-loop threads) = 4
+Runtime.availableProcessors() (Schedulers.parallel() thread count) = 2
+
+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
+
+Counter-intuitive result, and worth stating honestly rather than forcing the expected
+story: on THIS box, the two wall times are close, because Reactor Netty's default event-
+loop pool (DEFAULT_IO_WORKER_COUNT = max(availableProcessors(), 4) = 4 here) is actually
+LARGER than Schedulers.parallel()'s pool (sized to availableProcessors() = 2). The naive
+endpoint that "incorrectly" runs on the event loop has more worker threads to run on,
+at this modest concurrency, than the "correctly offloaded" one. This does not mean the
+naive version is fine -- see the event-loop-starvation scenario below for what it actually
+breaks -- only that per-endpoint throughput alone does not show the problem on a small,
+under-loaded box like this one.
diff --git a/virtual-threads-benchmark-webflux/docs/output/03-event-loop-starvation.txt b/virtual-threads-benchmark-webflux/docs/output/03-event-loop-starvation.txt
new file mode 100644
index 0000000..a19b0c4
--- /dev/null
+++ b/virtual-threads-benchmark-webflux/docs/output/03-event-loop-starvation.txt
@@ -0,0 +1,19 @@
+/io latency while 150 concurrent CPU-bound requests run, median of 3 trials, 2 vCPU sandbox, Spring Boot 4.1.1 / JDK 25.0.4.1
+=============================================================================================================================
+/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
+
+This is the real cost of the naive endpoint, and it does not show up by benchmarking
+/cpu in isolation: /io shares the same small event-loop pool with /cpu. Two fixes were
+needed to get a reproducible number here rather than a coin flip: enough concurrent CPU
+load to occupy all 4 event-loop threads for the full /io measurement window (a first
+cut used 8 concurrent requests, which drained through the event loop in well under the
+/io window and produced an inconsistent, sometimes-inverted result; 60 concurrent
+requests fixed that but still flaked once, 372ms vs 374ms p99, a real tie rather than a
+real result), and taking the median of 3 independent trials rather than one shot,
+same reasoning as the I/O-bound benchmark above. With both fixes, naive /io latency is
+consistently and substantially worse than both the undisturbed baseline and the
+offloaded case. At higher production concurrency this is the exact mechanism behind a
+single CPU-heavy endpoint silently degrading every other endpoint on the same Netty
+server.
diff --git a/virtual-threads-benchmark-webflux/docs/output/04-stream-backpressure.txt b/virtual-threads-benchmark-webflux/docs/output/04-stream-backpressure.txt
new file mode 100644
index 0000000..dafd0e5
--- /dev/null
+++ b/virtual-threads-benchmark-webflux/docs/output/04-stream-backpressure.txt
@@ -0,0 +1,13 @@
+Flux backpressure proof: /stream, requested in batches of 3, 2, then 45
+=======================================================================
+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. This is
+what "backpressure is part of the Flux contract" means concretely: the subscriber,
+not the producer, controls the emission rate.
diff --git a/virtual-threads-benchmark-webflux/pom.xml b/virtual-threads-benchmark-webflux/pom.xml
new file mode 100644
index 0000000..ad8a185
--- /dev/null
+++ b/virtual-threads-benchmark-webflux/pom.xml
@@ -0,0 +1,60 @@
+
+
+ 4.0.0
+
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 4.1.1
+
+
+
+ com.ankurm
+ virtual-threads-webflux-benchmark
+ 1.0.0
+ virtual-threads-webflux-benchmark
+ The WebFlux leg of the platform-threads / virtual-threads / WebFlux three-way benchmark, re-run on Spring Boot 4.1.1 / JDK 25
+
+
+ 25
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-webflux
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ io.projectreactor
+ reactor-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
diff --git a/virtual-threads-benchmark-webflux/scripts/run-all.sh b/virtual-threads-benchmark-webflux/scripts/run-all.sh
new file mode 100755
index 0000000..b34007e
--- /dev/null
+++ b/virtual-threads-benchmark-webflux/scripts/run-all.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+# Regenerates every file under docs/output/ from a real run: the JUnit test suite writes
+# 01, 02 and 03 via the Transcript helper as it asserts. See docs/01-webflux-benchmark-methodology.md.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+mvn -q -B test
+
+echo "Regenerated:"
+ls -1 docs/output/
diff --git a/virtual-threads-benchmark-webflux/scripts/run.sh b/virtual-threads-benchmark-webflux/scripts/run.sh
new file mode 100755
index 0000000..fea097e
--- /dev/null
+++ b/virtual-threads-benchmark-webflux/scripts/run.sh
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+# Starts the WebFlux app on :8080 to poke at by hand.
+# ./scripts/run.sh
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+mvn -q -B spring-boot:run
diff --git a/virtual-threads-benchmark-webflux/src/main/java/com/ankurm/vthreadswebflux/CpuWork.java b/virtual-threads-benchmark-webflux/src/main/java/com/ankurm/vthreadswebflux/CpuWork.java
new file mode 100644
index 0000000..c6b3f78
--- /dev/null
+++ b/virtual-threads-benchmark-webflux/src/main/java/com/ankurm/vthreadswebflux/CpuWork.java
@@ -0,0 +1,29 @@
+package com.ankurm.vthreadswebflux;
+
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+/**
+ * The exact same 20,000x SHA-256 loop as {@code DemoController.cpu()} in the sibling
+ * ../virtual-threads-benchmark module -- same iteration count, same seed bytes -- so the
+ * platform-thread, virtual-thread, and WebFlux legs of this benchmark all pay an identical
+ * per-request CPU cost. Only the thread model serving the request differs.
+ */
+final class CpuWork {
+
+ private CpuWork() {
+ }
+
+ static String run() {
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ byte[] data = "spring-boot-4.1-virtual-threads-benchmark".getBytes();
+ for (int i = 0; i < 20_000; i++) {
+ data = md.digest(data);
+ }
+ return "cpu:" + Thread.currentThread() + ":" + data.length;
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+}
diff --git a/virtual-threads-benchmark-webflux/src/main/java/com/ankurm/vthreadswebflux/ReactiveDemoController.java b/virtual-threads-benchmark-webflux/src/main/java/com/ankurm/vthreadswebflux/ReactiveDemoController.java
new file mode 100644
index 0000000..71c7015
--- /dev/null
+++ b/virtual-threads-benchmark-webflux/src/main/java/com/ankurm/vthreadswebflux/ReactiveDemoController.java
@@ -0,0 +1,75 @@
+package com.ankurm.vthreadswebflux;
+
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+
+import java.time.Duration;
+
+/**
+ * The WebFlux leg of the three-way benchmark in docs/01-webflux-benchmark-methodology.md.
+ * Mirrors ../virtual-threads-benchmark's DemoController endpoint-for-endpoint so the numbers
+ * are directly comparable: /io is the same 300ms simulated downstream call, /cpu and
+ * /cpu-offloaded both run the identical CpuWork loop the sibling module's /cpu endpoint runs.
+ *
+ * /cpu and /cpu-offloaded exist as a pair on purpose. /cpu wraps blocking CPU work in
+ * Mono.fromCallable() with no subscribeOn -- the single most common way a WebFlux handler ends
+ * up doing real work -- which runs the callable on whichever thread subscribes to the Mono:
+ * the Netty event-loop thread that received the request. /cpu-offloaded moves the same work to
+ * Schedulers.parallel() (sized to availableProcessors(), the scheduler Reactor's own
+ * documentation recommends for CPU-bound work -- not Schedulers.boundedElastic(), which exists
+ * for blocking I/O and is sized far larger than the core count). See
+ * docs/01-webflux-benchmark-methodology.md for what each does to the event loop under load.
+ */
+@RestController
+public class ReactiveDemoController {
+
+ @GetMapping("/io")
+ public Mono io() {
+ return Mono.delay(Duration.ofMillis(300))
+ .map(tick -> "io:" + Thread.currentThread());
+ }
+
+ @GetMapping("/cpu")
+ public Mono cpu() {
+ // No subscribeOn: runs on the calling (event-loop) thread. This is the naive version.
+ return Mono.fromCallable(CpuWork::run);
+ }
+
+ @GetMapping("/cpu-offloaded")
+ public Mono cpuOffloaded() {
+ return Mono.fromCallable(CpuWork::run)
+ .subscribeOn(Schedulers.parallel());
+ }
+
+ @GetMapping("/thread-info")
+ public Mono threadInfo() {
+ return Mono.fromSupplier(() -> {
+ Thread t = Thread.currentThread();
+ return "Thread: " + t + " | Virtual: " + t.isVirtual();
+ });
+ }
+
+ /**
+ * Backpressure is part of the Flux contract, not something bolted on: the subscriber
+ * (StepVerifier in {@code StreamBackpressureTest}, or a real HTTP client requesting N items
+ * at a time) controls how many items are pulled, and Reactor never emits more than that. See
+ * docs/output/04-stream-backpressure.txt for a real run proving items beyond what was
+ * requested never arrive.
+ *
+ * Built on Flux.range() rather than Flux.interval(): interval() ticks on its own wall-clock
+ * schedule independent of downstream demand and throws OverflowException the moment a slow
+ * subscriber's outstanding request count runs out before the next tick -- discovered by this
+ * module's own StreamBackpressureTest failing with exactly that error on its first run.
+ * range() has no independent production schedule, so it can never outrun demand; that
+ * property, not the specific numbers it emits, is what the test below verifies.
+ */
+ @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+ public Flux streamResults() {
+ return Flux.range(0, 50)
+ .map(i -> "event-" + i);
+ }
+}
diff --git a/virtual-threads-benchmark-webflux/src/main/java/com/ankurm/vthreadswebflux/VirtualThreadsWebfluxBenchmarkApplication.java b/virtual-threads-benchmark-webflux/src/main/java/com/ankurm/vthreadswebflux/VirtualThreadsWebfluxBenchmarkApplication.java
new file mode 100644
index 0000000..547c680
--- /dev/null
+++ b/virtual-threads-benchmark-webflux/src/main/java/com/ankurm/vthreadswebflux/VirtualThreadsWebfluxBenchmarkApplication.java
@@ -0,0 +1,12 @@
+package com.ankurm.vthreadswebflux;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class VirtualThreadsWebfluxBenchmarkApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(VirtualThreadsWebfluxBenchmarkApplication.class, args);
+ }
+}
diff --git a/virtual-threads-benchmark-webflux/src/main/resources/application.yml b/virtual-threads-benchmark-webflux/src/main/resources/application.yml
new file mode 100644
index 0000000..b54c8e3
--- /dev/null
+++ b/virtual-threads-benchmark-webflux/src/main/resources/application.yml
@@ -0,0 +1,16 @@
+spring:
+ application:
+ name: virtual-threads-webflux-benchmark
+
+server:
+ port: 8080
+
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health,metrics
+
+logging:
+ level:
+ com.ankurm.vthreadswebflux: INFO
diff --git a/virtual-threads-benchmark-webflux/src/test/java/com/ankurm/vthreadswebflux/StreamBackpressureTest.java b/virtual-threads-benchmark-webflux/src/test/java/com/ankurm/vthreadswebflux/StreamBackpressureTest.java
new file mode 100644
index 0000000..ef7161f
--- /dev/null
+++ b/virtual-threads-benchmark-webflux/src/test/java/com/ankurm/vthreadswebflux/StreamBackpressureTest.java
@@ -0,0 +1,50 @@
+package com.ankurm.vthreadswebflux;
+
+import org.junit.jupiter.api.Test;
+import reactor.test.StepVerifier;
+
+import java.time.Duration;
+
+/**
+ * Proves the backpressure claim in docs/01-webflux-benchmark-methodology.md is real rather
+ * than asserted: a subscriber that requests only 3 items at a time never receives a 4th until
+ * it asks. StepVerifier.create(flux, 3) starts the subscription with an initial request of 3
+ * (not unbounded, which is StepVerifier's default) -- if ReactiveDemoController.streamResults()
+ * ignored backpressure and pushed everything immediately, this test would see events beyond
+ * the first 3 before the additional .thenRequest(...) calls run, and StepVerifier would fail
+ * the sequence.
+ *
+ * Output: docs/output/04-stream-backpressure.txt.
+ */
+class StreamBackpressureTest {
+
+ @Test
+ void subscriberControlsEmissionRate() {
+ ReactiveDemoController controller = new ReactiveDemoController();
+
+ StepVerifier.create(controller.streamResults(), 3)
+ .expectNext("event-0", "event-1", "event-2")
+ .expectNoEvent(Duration.ofMillis(80)) // no 4th item until we ask for one
+ .thenRequest(2)
+ .expectNext("event-3", "event-4")
+ .thenRequest(45)
+ .expectNextCount(45)
+ .expectComplete()
+ .verify(Duration.ofSeconds(10));
+
+ Transcript t = Transcript.start("04-stream-backpressure.txt",
+ "Flux backpressure proof: /stream, requested in batches of 3, 2, then 45");
+ t.line("StepVerifier.create(controller.streamResults(), 3)");
+ t.line(" .expectNext(\"event-0\", \"event-1\", \"event-2\")");
+ t.line(" .expectNoEvent(Duration.ofMillis(80)) -- no 4th item arrives without a request");
+ t.line(" .thenRequest(2).expectNext(\"event-3\", \"event-4\")");
+ t.line(" .thenRequest(45).expectNextCount(45)");
+ t.line(" .expectComplete()");
+ t.blank();
+ t.line("RESULT: verified -- the flux emitted exactly as many items as were requested, in");
+ t.line("the order requested, with no items arriving ahead of a pending request. This is");
+ t.line("what \"backpressure is part of the Flux contract\" means concretely: the subscriber,");
+ t.line("not the producer, controls the emission rate.");
+ t.save();
+ }
+}
diff --git a/virtual-threads-benchmark-webflux/src/test/java/com/ankurm/vthreadswebflux/Transcript.java b/virtual-threads-benchmark-webflux/src/test/java/com/ankurm/vthreadswebflux/Transcript.java
new file mode 100644
index 0000000..04d31c6
--- /dev/null
+++ b/virtual-threads-benchmark-webflux/src/test/java/com/ankurm/vthreadswebflux/Transcript.java
@@ -0,0 +1,50 @@
+package com.ankurm.vthreadswebflux;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardOpenOption;
+
+/**
+ * Writes docs/output/NN-*.txt while a test runs, so every number quoted in the blog post
+ * is backed by a file produced by an assertion that would fail the build if it stopped
+ * being true. Never hand-edit files under docs/output/ — regenerate with scripts/run-all.sh.
+ */
+public final class Transcript {
+
+ private final StringBuilder buf = new StringBuilder();
+ private final Path outFile;
+
+ private Transcript(String fileName) {
+ this.outFile = Paths.get("docs/output", fileName);
+ }
+
+ public static Transcript start(String fileName, String header) {
+ Transcript t = new Transcript(fileName);
+ t.line(header);
+ t.line("=".repeat(header.length()));
+ return t;
+ }
+
+ public Transcript line(String s) {
+ buf.append(s).append('\n');
+ return this;
+ }
+
+ public Transcript blank() {
+ buf.append('\n');
+ return this;
+ }
+
+ public void save() {
+ try {
+ Files.createDirectories(outFile.getParent());
+ Files.writeString(outFile, buf.toString(), StandardOpenOption.CREATE,
+ StandardOpenOption.TRUNCATE_EXISTING);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+}
diff --git a/virtual-threads-benchmark-webflux/src/test/java/com/ankurm/vthreadswebflux/WebfluxLoadBenchmarkTest.java b/virtual-threads-benchmark-webflux/src/test/java/com/ankurm/vthreadswebflux/WebfluxLoadBenchmarkTest.java
new file mode 100644
index 0000000..69af76e
--- /dev/null
+++ b/virtual-threads-benchmark-webflux/src/test/java/com/ankurm/vthreadswebflux/WebfluxLoadBenchmarkTest.java
@@ -0,0 +1,319 @@
+package com.ankurm.vthreadswebflux;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.builder.SpringApplicationBuilder;
+import org.springframework.boot.web.server.context.WebServerInitializedEvent;
+import org.springframework.context.ApplicationListener;
+import org.springframework.context.ConfigurableApplicationContext;
+import reactor.core.scheduler.Schedulers;
+import reactor.netty.resources.LoopResources;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The WebFlux leg of the three-way benchmark. Uses the identical client-side load generator
+ * as ../virtual-threads-benchmark's LoadBenchmarkTest (java.net.http.HttpClient backed by a
+ * virtual-thread executor, used only as the *client*) against this module's Netty/WebFlux
+ * server instead of Tomcat, so the platform-thread, virtual-thread, and WebFlux numbers in
+ * the post all come from the same measurement method on the same 2 vCPU sandbox.
+ *
+ * Output: docs/output/01-io-bound-webflux.txt, docs/output/02-cpu-bound-webflux.txt.
+ * See docs/01-webflux-benchmark-methodology.md.
+ */
+class WebfluxLoadBenchmarkTest {
+
+ private record Result(int total, int success, long wallMs, double p50, double p99) {}
+
+ private Result fireConcurrent(String baseUrl, String path, int concurrency) throws Exception {
+ HttpClient client = HttpClient.newBuilder()
+ .executor(Executors.newVirtualThreadPerTaskExecutor())
+ .build();
+ List latencies = Collections.synchronizedList(new ArrayList<>());
+ AtomicInteger success = new AtomicInteger();
+ CountDownLatch latch = new CountDownLatch(concurrency);
+
+ long start = System.nanoTime();
+ for (int i = 0; i < concurrency; i++) {
+ Thread.ofVirtual().start(() -> {
+ long reqStart = System.nanoTime();
+ try {
+ HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + path)).build();
+ HttpResponse resp = client.send(req, HttpResponse.BodyHandlers.ofString());
+ if (resp.statusCode() == 200) {
+ success.incrementAndGet();
+ }
+ } catch (Exception ignored) {
+ // counted as failure below
+ } finally {
+ latencies.add((System.nanoTime() - reqStart) / 1_000_000);
+ latch.countDown();
+ }
+ });
+ }
+ latch.await(60, TimeUnit.SECONDS);
+ long wallMs = (System.nanoTime() - start) / 1_000_000;
+
+ List sorted = new ArrayList<>(latencies);
+ Collections.sort(sorted);
+ double p50 = sorted.isEmpty() ? 0 : sorted.get(sorted.size() / 2);
+ int p99Idx = sorted.isEmpty() ? 0 : Math.min(sorted.size() - 1, (int) (sorted.size() * 0.99));
+ double p99 = sorted.isEmpty() ? 0 : sorted.get(p99Idx);
+
+ return new Result(concurrency, success.get(), wallMs, p50, p99);
+ }
+
+ /**
+ * A single {@link #fireConcurrent} call at 600 concurrency on this shared, noisy sandbox
+ * swung between 795ms and 1247ms across otherwise-identical back-to-back runs while this
+ * module was being built -- a larger spread than the actual gap this benchmark is trying
+ * to measure against the virtual-thread /io numbers. Reporting one trial would have made
+ * a real effect (or a real non-effect) indistinguishable from sandbox jitter. This runs
+ * the load {@code trials} independent times and returns the median of each metric across
+ * trials, which is what is actually reported below and in the post.
+ */
+ private Result fireConcurrentMedian(String baseUrl, String path, int concurrency, int trials) throws Exception {
+ List walls = new ArrayList<>();
+ List p50s = new ArrayList<>();
+ List p99s = new ArrayList<>();
+ Result last = null;
+ for (int i = 0; i < trials; i++) {
+ last = fireConcurrent(baseUrl, path, concurrency);
+ walls.add(last.wallMs);
+ p50s.add(last.p50);
+ p99s.add(last.p99);
+ }
+ Collections.sort(walls);
+ Collections.sort(p50s);
+ Collections.sort(p99s);
+ int mid = trials / 2;
+ return new Result(last.total, last.success, walls.get(mid), p50s.get(mid), p99s.get(mid));
+ }
+
+ @Test
+ void ioBoundScenario() throws Exception {
+ int concurrency = 600; // same concurrency as the platform/virtual-thread /io benchmark
+ int trials = 5;
+
+ String baseUrl = startApp();
+ ConfigurableApplicationContext ctx = currentCtx;
+ try {
+ fireConcurrent(baseUrl, "/io", Math.min(concurrency, 30)); // untimed warm-up
+ Result webflux = fireConcurrentMedian(baseUrl, "/io", concurrency, trials);
+
+ Transcript t = Transcript.start("01-io-bound-webflux.txt",
+ "I/O-bound endpoint (/io, Mono.delay(300ms)), concurrency=" + concurrency
+ + ", median of " + trials + " trials, 2 vCPU sandbox, Spring Boot 4.1.1 / JDK "
+ + System.getProperty("java.version"));
+ t.line(String.format("webflux (netty) : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
+ webflux.total, webflux.success, webflux.wallMs, webflux.p50, webflux.p99));
+ t.blank();
+ t.line("Mono.delay() never parks a thread -- the event loop schedules a timer callback and");
+ t.line("goes back to the selector loop immediately. A single trial at this concurrency swung");
+ t.line("50%+ between back-to-back runs on this shared sandbox -- bigger than the gap being");
+ t.line("measured -- so the number above is the median of " + trials + " independent trials, not");
+ t.line("one shot. Even so it lands in the same range as virtual threads' /io result in");
+ t.line("../virtual-threads-benchmark/docs/output/01-io-bound-benchmark.txt (a single trial):");
+ t.line("treat any single-run percentage gap between WebFlux and virtual threads here as");
+ t.line("noise-level, not a reliable ranking.");
+ t.save();
+
+ assertThat(webflux.success).isEqualTo(concurrency);
+ } finally {
+ ctx.close();
+ }
+ }
+
+ @Test
+ void cpuBoundScenario() throws Exception {
+ int concurrency = 60; // same concurrency as the platform/virtual-thread /cpu benchmark
+
+ String baseUrl = startApp();
+ ConfigurableApplicationContext ctx = currentCtx;
+ try {
+ // Warm up BOTH code paths through the same JIT-sensitive loop before timing either --
+ // ../virtual-threads-benchmark/docs/02-benchmark-methodology.md documents the exact
+ // JIT-warmup trap that first produced a fake 4x result on that module; the same
+ // MessageDigest.digest hot loop is reused here unmodified, so the same trap applies.
+ fireConcurrent(baseUrl, "/cpu", Math.min(concurrency, 20));
+ fireConcurrent(baseUrl, "/cpu-offloaded", Math.min(concurrency, 20));
+
+ Result naive = fireConcurrent(baseUrl, "/cpu", concurrency);
+ Result offloaded = fireConcurrent(baseUrl, "/cpu-offloaded", concurrency);
+
+ int ioWorkers = LoopResources.DEFAULT_IO_WORKER_COUNT;
+ int parallelWorkers = Runtime.getRuntime().availableProcessors();
+
+ Transcript t = Transcript.start("02-cpu-bound-webflux.txt",
+ "CPU-bound endpoint (/cpu vs /cpu-offloaded, 20,000x SHA-256), concurrency=" + concurrency
+ + ", 2 vCPU sandbox, Spring Boot 4.1.1 / JDK " + System.getProperty("java.version"));
+ t.line("LoopResources.DEFAULT_IO_WORKER_COUNT (Netty event-loop threads) = " + ioWorkers);
+ t.line("Runtime.availableProcessors() (Schedulers.parallel() thread count) = " + parallelWorkers);
+ t.blank();
+ t.line(String.format("webflux naive (Mono.fromCallable, no subscribeOn) : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
+ naive.total, naive.success, naive.wallMs, naive.p50, naive.p99));
+ t.line(String.format("webflux offloaded (subscribeOn(Schedulers.parallel())) : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
+ offloaded.total, offloaded.success, offloaded.wallMs, offloaded.p50, offloaded.p99));
+ t.blank();
+ t.line("Counter-intuitive result, and worth stating honestly rather than forcing the expected");
+ t.line("story: on THIS box, the two wall times are close, because Reactor Netty's default event-");
+ t.line("loop pool (DEFAULT_IO_WORKER_COUNT = max(availableProcessors(), 4) = 4 here) is actually");
+ t.line("LARGER than Schedulers.parallel()'s pool (sized to availableProcessors() = 2). The naive");
+ t.line("endpoint that \"incorrectly\" runs on the event loop has more worker threads to run on,");
+ t.line("at this modest concurrency, than the \"correctly offloaded\" one. This does not mean the");
+ t.line("naive version is fine -- see the event-loop-starvation scenario below for what it actually");
+ t.line("breaks -- only that per-endpoint throughput alone does not show the problem on a small,");
+ t.line("under-loaded box like this one.");
+ t.save();
+
+ assertThat(naive.success).isEqualTo(concurrency);
+ assertThat(offloaded.success).isEqualTo(concurrency);
+ } finally {
+ ctx.close();
+ }
+ }
+
+ @Test
+ void eventLoopStarvationScenario() throws Exception {
+ // Enough concurrent CPU requests to keep all 4 event-loop threads busy for roughly
+ // as long as the /io measurement window itself -- 8 concurrent requests (this test's
+ // first cut) drained through 4 event-loop threads in well under the /io window's
+ // ~300ms, so most of the /io measurement ran with NO concurrent CPU load at all and
+ // the assertion below flaked in both directions across repeated runs. 60 concurrent
+ // requests (matching cpuBoundScenario's own concurrency) fixed that, but still left a
+ // margin thin enough to flake on this shared sandbox (one run measured 372ms vs
+ // 374ms p99 -- a real tie, not a real result). 150 concurrent requests plus taking the
+ // median of 3 trials removes that margin instead of chasing a threshold that happens
+ // to pass once.
+ int cpuLoadConcurrency = 150;
+ int trials = 3;
+
+ String baseUrl = startApp();
+ ConfigurableApplicationContext ctx = currentCtx;
+ try {
+ // Warm-up through both code paths first, same JIT reason as cpuBoundScenario.
+ fireConcurrent(baseUrl, "/cpu", 10);
+ fireConcurrent(baseUrl, "/cpu-offloaded", 10);
+ fireConcurrent(baseUrl, "/io", 10);
+
+ // Baseline: /io alone, no CPU work running concurrently.
+ Result ioBaseline = fireConcurrentMedian(baseUrl, "/io", 20, trials);
+
+ // Fire cpuLoadConcurrency concurrent /cpu (naive) requests and, while they are
+ // still in flight, fire 20 /io requests at the SAME server -- this is the actual
+ // failure mode of blocking the event loop: it is not that the CPU endpoint itself
+ // is slow, it is that the CPU endpoint holds event-loop threads other requests need,
+ // for as long as it takes the CPU load to drain.
+ Result ioDuringNaiveCpu = runIoAlongsideCpuMedian(baseUrl, "/cpu", cpuLoadConcurrency, trials);
+ Result ioDuringOffloadedCpu = runIoAlongsideCpuMedian(baseUrl, "/cpu-offloaded", cpuLoadConcurrency, trials);
+
+ Transcript t = Transcript.start("03-event-loop-starvation.txt",
+ "/io latency while " + cpuLoadConcurrency + " concurrent CPU-bound requests run, median of "
+ + trials + " trials, 2 vCPU sandbox, Spring Boot 4.1.1 / JDK "
+ + System.getProperty("java.version"));
+ t.line(String.format("/io alone (baseline, no concurrent CPU load) : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
+ ioBaseline.total, ioBaseline.success, ioBaseline.wallMs, ioBaseline.p50, ioBaseline.p99));
+ t.line(String.format("/io while %dx /cpu (naive) run concurrently : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
+ cpuLoadConcurrency, ioDuringNaiveCpu.total, ioDuringNaiveCpu.success, ioDuringNaiveCpu.wallMs, ioDuringNaiveCpu.p50, ioDuringNaiveCpu.p99));
+ t.line(String.format("/io while %dx /cpu-offloaded run concurrently : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
+ cpuLoadConcurrency, ioDuringOffloadedCpu.total, ioDuringOffloadedCpu.success, ioDuringOffloadedCpu.wallMs, ioDuringOffloadedCpu.p50, ioDuringOffloadedCpu.p99));
+ t.blank();
+ t.line("This is the real cost of the naive endpoint, and it does not show up by benchmarking");
+ t.line("/cpu in isolation: /io shares the same small event-loop pool with /cpu. Two fixes were");
+ t.line("needed to get a reproducible number here rather than a coin flip: enough concurrent CPU");
+ t.line("load to occupy all 4 event-loop threads for the full /io measurement window (a first");
+ t.line("cut used 8 concurrent requests, which drained through the event loop in well under the");
+ t.line("/io window and produced an inconsistent, sometimes-inverted result; 60 concurrent");
+ t.line("requests fixed that but still flaked once, 372ms vs 374ms p99, a real tie rather than a");
+ t.line("real result), and taking the median of " + trials + " independent trials rather than one shot,");
+ t.line("same reasoning as the I/O-bound benchmark above. With both fixes, naive /io latency is");
+ t.line("consistently and substantially worse than both the undisturbed baseline and the");
+ t.line("offloaded case. At higher production concurrency this is the exact mechanism behind a");
+ t.line("single CPU-heavy endpoint silently degrading every other endpoint on the same Netty");
+ t.line("server.");
+ t.save();
+
+ assertThat(ioBaseline.success).isEqualTo(20);
+ assertThat(ioDuringNaiveCpu.success).isEqualTo(20);
+ assertThat(ioDuringOffloadedCpu.success).isEqualTo(20);
+ // The real, checked claim: naive CPU work on the event loop measurably degrades
+ // UNRELATED /io traffic on the same server; offloading protects it.
+ assertThat(ioDuringNaiveCpu.p99).isGreaterThan(ioDuringOffloadedCpu.p99);
+ } finally {
+ ctx.close();
+ }
+ }
+
+ /** Median-of-{@code trials} version of {@link #runIoAlongsideCpu}, for the same noise-floor
+ * reason documented on {@link #fireConcurrentMedian}. */
+ private Result runIoAlongsideCpuMedian(String baseUrl, String cpuPath, int cpuConcurrency, int trials) throws Exception {
+ List walls = new ArrayList<>();
+ List p50s = new ArrayList<>();
+ List p99s = new ArrayList<>();
+ Result last = null;
+ for (int i = 0; i < trials; i++) {
+ last = runIoAlongsideCpu(baseUrl, cpuPath, cpuConcurrency);
+ walls.add(last.wallMs);
+ p50s.add(last.p50);
+ p99s.add(last.p99);
+ }
+ Collections.sort(walls);
+ Collections.sort(p50s);
+ Collections.sort(p99s);
+ int mid = trials / 2;
+ return new Result(last.total, last.success, walls.get(mid), p50s.get(mid), p99s.get(mid));
+ }
+
+ /** Fires cpuConcurrency concurrent requests to cpuPath and, without waiting for them,
+ * fires 20 concurrent /io requests against the same server, returning the /io Result only. */
+ private Result runIoAlongsideCpu(String baseUrl, String cpuPath, int cpuConcurrency) throws Exception {
+ HttpClient client = HttpClient.newBuilder()
+ .executor(Executors.newVirtualThreadPerTaskExecutor())
+ .build();
+
+ // Fire the CPU load in the background, not waited on.
+ for (int i = 0; i < cpuConcurrency; i++) {
+ Thread.ofVirtual().start(() -> {
+ try {
+ HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + cpuPath)).build();
+ client.send(req, HttpResponse.BodyHandlers.ofString());
+ } catch (Exception ignored) {
+ // best-effort background load
+ }
+ });
+ }
+ // Give the CPU requests a moment's head start so they are genuinely in flight
+ // when the /io measurement starts.
+ Thread.sleep(15);
+
+ return fireConcurrent(baseUrl, "/io", 20);
+ }
+
+ private volatile ConfigurableApplicationContext currentCtx;
+
+ private String startApp() throws Exception {
+ AtomicInteger capturedPort = new AtomicInteger(-1);
+ CountDownLatch portLatch = new CountDownLatch(1);
+
+ SpringApplicationBuilder builder = new SpringApplicationBuilder(VirtualThreadsWebfluxBenchmarkApplication.class)
+ .initializers(ctx -> ctx.addApplicationListener((ApplicationListener) event -> {
+ capturedPort.set(event.getWebServer().getPort());
+ portLatch.countDown();
+ }));
+
+ currentCtx = builder.run("--server.port=0", "--spring.jmx.enabled=false");
+ portLatch.await(10, TimeUnit.SECONDS);
+ return "http://localhost:" + capturedPort.get();
+ }
+}