The Falsifier That Never Fired
Contents
12:56 UTC: a prediction with its kill condition attached#
2026-09-02, 12:56 UTC. Scenario 107 of 172. The benchmark is one uninterrupted pass — 18 branches, three frameworks, about 35 hours end to end — and it is past the halfway mark. Micronaut's six branches are done. Quarkus is on its last. Spring Boot's six have not started.
On the reactive fan-out workload, at the JVM 256MB tier, two results are already in:
| framework | pool library | rps | p95 | real failures |
|---|---|---|---|---|
| Micronaut | r2dbc-pool | 37.7 | 15000.75 ms | 40.19% |
| Quarkus | Vert.x PG client | 260.1 | 2099.23 ms | 0.00% |
Same workload, same tier, same equalized topology. Micronaut sheds four requests in ten and parks its p95 on the client timeout. Quarkus does seven times the throughput and drops nothing.
The cause of that was not news. In July, Eight Connections, Zero Available caught Micronaut and Spring reactive stalled at 60.8 and 59.9 rps on native 128MB — two frameworks on the same wall — and traced it to r2dbc-pool 1.0.2 and issue #198, then titled "Connection leaks (not released) when concurrent query is canceled": cancel one query in a zip, and the other query's connection never goes back to the pool. Eight of those and the pool has nothing left to lend. Memory isn't the mechanism. Pressure is — a tight tier slows responses past k6's 15-second timeout, the timeouts become cancellations, and the cancellations leak.
Since July that explanation had sat in the project notes as settled. Settled meant: seen once, in one cell of the matrix, on an older run, explained, filed. Nothing since had been given a chance to contradict it. This is a new run, a different runtime, a different tier. If the July story was a coincidence that happened to fit, this is where it stops fitting.
At 12:56:46 UTC, with Spring Boot's six branches still queued, I wrote the prediction down. The reasoning was short. Spring's reactive tier borrows its connections from the same r2dbc-pool that Micronaut's does, so at 256m it ought to fall over the way Micronaut had, and Quarkus — no r2dbc-pool anywhere — ought to stay healthy. Then the falsifier: a clean Spring would mean the pool was the wrong suspect and the fault lived somewhere inside Micronaut. The remaining branches would decide it whichever way it went.
The falsifier is the part with teeth. It names, in advance, the result that would kill the recorded cause. A healthy Spring wouldn't have been a footnote — it would have meant the July finding was a coincidence wearing a lab coat, and sent the investigation back into Micronaut's internals for a different culprit.
12:56 to 23:01: ten hours of leaving it alone#
Nothing to do but not touch it. The temptation, with Micronaut on the floor and a theory in hand, is to go fix Micronaut — tune the pool, rerun the branch. Do that and the theory stays a theory forever: the symptom is gone before a second stack gets to test it. The repository's git log has no commits between 11:29 and 22:51 UTC.
Spring's imperative branches ran first, through the afternoon and evening. At 21:23 UTC the first Spring reactive branch started — reactive-r2dbc-synth, which also sits on r2dbc-pool. It came through at 2520 rps, 0.8 ms p95, zero failures. Not an answer: at a 0.8 ms p95 nothing comes anywhere near a timeout, so nothing gets cancelled. A leak that needs cancellations has nothing to leak.
At 22:51:10 UTC the first Prometheus scrape of springboot/reactive-synth, jvm-synth-256mb landed. Its k6-summary.json was written at 23:01:55.
Ten hours and five minutes from the prediction to the answer.
23:01 UTC: Spring wedges, and harder than Micronaut#
Real http_req_failed from k6-summary.json, not the conflated Error rate: line:
| framework | pool library | rps | p95 | real failures |
|---|---|---|---|---|
| Micronaut | r2dbc-pool | 37.7 | 15000.75 ms | 40.19% |
| Spring | r2dbc-pool | 13.6 | 15000.84 ms | 57.95% |
| Quarkus | Vert.x PG client | 260.1 | 2099.23 ms | 0.00% |
The falsifier did not fire. Spring didn't come through clean — it failed harder than Micronaut: about a third of the throughput, nearly eighteen more points of failure. Both r2dbc-pool ports have a p95 pinned to the millisecond at the 15-second client timeout: 15000.75 and 15000.84. A slow tail spreads out. This one is flat against the ceiling, every request in it killed by the client at the same instant — the mass-abort signature of a pool that has stopped handing connections back.
Quarkus, same workload, same tier: 260 rps, zero failures, 19 times Spring's throughput, on a client that never touches r2dbc-pool. Both halves of the prediction held. The two ports on the library wedge; the one off it doesn't.
The unlimited tier is the free control group#
Being right about a collapse is worth little if the three frameworks disagree everywhere anyway — then "Spring collapsed" is just Spring being Spring. Same three ports, unlimited memory, where nothing is starved and nothing times out:
| framework | rps | real failures |
|---|---|---|
| Micronaut | 278.8 | 0.00% |
| Spring | 278.4 | 0.00% |
| Quarkus | 275.7 | 0.00% |
A spread of 3.1 rps across all three — 1.1%. Spring is nominally the fastest. Nineteen-to-one at 256MB, dead even with headroom.
That's what makes this about a dependency rather than a framework. A weaker stack would trail at unlimited too. This one leads. The only thing that changes between the tier where the three agree and the tier where they split 19x is whether r2dbc-pool has been pushed into its cancellation path.
Degraded vs. wedged: the scrape that never came back#
Throughput and error rate say a port is degraded. They don't say whether the process is still answering at all.
The Prometheus scraper polls each app on a two-second sleep — about every three seconds in practice, slower while an app hangs — and writes a row. When the app can't serve its own /prometheus endpoint, the row comes back with a timestamp and nothing else. Count the empty rows in prom.csv, and the restarts in oom.txt:
| framework | scrape samples | app answered | blank | container restarts |
|---|---|---|---|---|
| Quarkus | 209 | 209 | 0% | 0 |
| Micronaut | 147 | 110 | 25% | 1 |
| Spring | 103 | 37 | 64% | 0 |
Quarkus never missed a scrape. Micronaut missed a quarter and its container restarted once. Spring missed nearly two-thirds and never restarted: oom_killed=false, restart_count=0, status=running. The JVM stayed up and went catatonic — last answered scrape at 169 seconds of uptime, then empty rows to the end of the run. Quarkus was still answering at 701.
"13.6 rps" reads as slow. Sixty-four percent empty scrapes reads as the process is no longer serving anything, which is the actual state — and the one that matters to whoever is staring at a green pod with no traffic going through it. oom.txt says running. The app hasn't said a word since 169 seconds in.
Pool occupancy, the instrument built for this, read zero everywhere#
The part I got wrong first. To show the wedge, I reached for the signal added for exactly this: active backends, sampled from pg_stat_activity into pg_backends_active. A pool leaking itself shut should light that up.
It didn't discriminate at all. Mean active backends: 0.10 on Micronaut, 0.03 on Spring, 0.25 on perfectly healthy Quarkus. The reason is dull and instructive. The DB calls in this workload are fast, and a sampler that fires every few seconds almost never catches one mid-statement. And a leaked connection is doing nothing at all: the #198 report watched one in pg_stat_activity and found it "has not changed its state since" its last statement. A snapshot of executing backends cannot see a connection that the pool counts as borrowed and Postgres counts as parked.
The instrument aimed at the database saw nothing, on the run where the database pool was the whole story. What separated the three was the scraper honestly recording its own failures — a measurement of the app, taken for an unrelated reason.
What a stated falsifier buys, and what it can't reach#
A prediction without a falsifier is a horoscope: it survives any outcome because it never named the outcome that would kill it. Guessing right about your own recorded root cause is close to free. What mattered is that the guess was set up to be caught if it was wrong, and then wasn't.
At 12:56 the r2dbc-pool cause was a July table and a sentence in a notes file. At 23:01 it had replicated in a cell it had never been tested in: a second user of the suspected stack failing the same way, a non-user sailing through, and an unlimited tier where all three agree. Same words in the notes. Different weight behind them.
The limit is in the same sentence. A falsifier can only kill what the contestants don't share. Micronaut and Spring share r2dbc-pool — and, one layer down, the r2dbc-postgresql driver. Quarkus has neither. The prediction said "r2dbc-pool" because that was the name on the issue. Nothing in this run could have told the pool from the driver, and nothing tried.
2026-09-17: #198 closes, filed under someone else's project#
Two weeks after the run, r2dbc-pool closed #198 as not planned. New label: for: external-project — "For an external project and not something we can fix." New title: "Connection not released when concurrent query is canceled." The fix went to the driver: r2dbc-postgresql #731, "Release results that are discarded after cancellation", milestoned for 1.1.3.RELEASE, which reached Maven Central on 2026-09-21. This run predates it.
The conclusion survives untouched: two ports on a shared Postgres stack wedge under cancellation, the port off it doesn't, and at unlimited all three agree. If your reactive service reaches Postgres through r2dbc-postgresql behind r2dbc-pool, and your clients cancel under load — timeouts, disconnects, a load generator giving up at 15 seconds — you were in this failure path whatever the logo on top says. Quarkus came through untouched because the Vert.x PG client is neither of those jars.
Every number from 23:01 still stands. The culprit's name moved one jar down.
Filed under: java, micronaut, quarkus, spring-boot, benchmarks, r2dbc, reactive
Read next#
- Eight Connections, Zero Available — where the r2dbc-pool cause was first traced: two frameworks on the same 60 rps wall, and a pool that thought eight idle connections were busy.
- The Baseline Was the Bug — the same run, earlier: the JVM "platform-thread" baseline had been running virtual threads for seven weeks.
- Eighteen Requests for One Redirect — the previous part: one
302from a stub cost four client stacks 18, 6, 3 and 1 requests before the fix. - 265 req/s on All Three Runtimes (Until You Add a Memory Limit) — where this reactive workload comes from:
/api/surveyfiring 40 weather calls at concurrency 8, all three runtimes tied at 265 req/s until the memory limit dropped.
Sources#
- Benchmark run
20260901-174554,{micronaut,quarkus,springboot}/reactive-synth/…/jvm-synth-256mb/—k6-summary.json(http_reqs.rate,http_req_duration.p(95),http_req_failed.rate),prom.csv(blank scrapes = empty fields afterts;uptimecol 14;pg_backends_activecol 24) andoom.txt(restart_count,oom_killed,status). 172 scenarios / 18 branches. Internal, unpublished. - Same run,
…/jvm-synth-unlimited/k6-summary.json— the convergence control (278.8 / 278.4 / 275.7 rps, 0% each);springboot/reactive-r2dbc-synth/…/jvm-synth-256mb/k6-summary.json— the non-answer (2520.3 rps, 0%). - Timeline anchors — prediction: working-notes entry timestamped 2026-09-02T12:56:46Z (paraphrased above); answer: first
prom.csvrow 2026-09-02T22:51:10Z andk6-summary.jsonmtime 2026-09-02T23:01:55Z forspringboot/reactive-synth/…/jvm-synth-256mb. Commit gap:git logshows b095d34e at 11:29:50Z and fb07fffe at 22:51:06Z, nothing between. - Branch
pom.xmls (git show origin/<fw>/<branch>:pom.xml) — Micronautmicronaut-r2dbc-core+r2dbc-postgresql, Springspring-boot-starter-r2dbc+r2dbc-postgresql+r2dbc-pool(resolved1.0.2.RELEASE) vs Quarkusquarkus-reactive-pg-client(Vert.x). Internal. - r2dbc-pool issue #198 — titled "Connection leaks (not released) when concurrent query is canceled" during the run; retitled "Connection not released when concurrent query is canceled", labeled
for: external-projectand closed as not planned on 2026-09-17. Thepg_stat_activityquote is from the original report. - r2dbc-postgresql PR #731 — "Release results that are discarded after cancellation"; closed in commit
2daf341on 2026-09-17, milestone1.1.3.RELEASE. - r2dbc-postgresql on Maven Central —
1.1.3.RELEASEpublished 2026-09-21.