2492, 2495, 2508

The Memory Benchmark · Part 28

Part 19 of the Micronaut native image series. Follows Part 18, which chased a connection pool cancellation leak. Assumes familiarity with JDBC, R2DBC, and JPA/Hibernate.

The Table That Ended the Argument

WorkloadFrameworkrpsp95
jdbc-synthQuarkus2508.41.2 ms
jdbc-synthMicronaut2495.01.8 ms
jdbc-synthSpring Boot2491.81.6 ms
r2dbc-synthQuarkus2501.21.4 ms
r2dbc-synthMicronaut2480.22.2 ms
r2dbc-synthSpring Boot2478.72.3 ms

Native images at 256 MB. Same k6 load. Same Postgres. Zero errors across the board.

Three frameworks. Six rows. A 0.7% spread on JDBC. A 0.9% spread on R2DBC. At this resolution, the difference is thermal noise and thread scheduling — not framework architecture.

What Was Actually Being Measured

The original benchmark shipped each framework with its idiomatic persistence stack:

  • Micronaut: Micronaut Data — compile-time repositories, generated queries, its own connection management wrapper
  • Spring Boot: Hand-rolled JdbcClient — explicit SQL, manual row mapping, Spring’s transaction manager
  • Quarkus: Hibernate with Panache — runtime reflection, entity scanning, Panache’s active record layer, Agroal pool

Three frameworks, yes. But also three query generators. Three batching strategies. Three connection management layers. Three different amounts of work per SQL statement.

When Quarkus ran slower on CRUD, was that Quarkus — or was it Hibernate scanning entity metadata at runtime while Micronaut’s compile-time repositories skipped that entirely? When Spring Boot edged ahead on a particular query pattern, was that Tomcat’s thread model — or was it JdbcClient generating simpler SQL than Micronaut Data’s method-name-derived queries?

The benchmark couldn’t answer because the question was confounded.

The Equalization

The fix was brutal: rip out every ORM and replace it with hand-written SQL, identical across all three.

Imperative: Plain JDBC. PreparedStatement, ResultSet, manual row mapping. One DAO file, one domain file, byte-identical across Micronaut, Quarkus, and Spring Boot.

Reactive: Raw R2DBC with Reactor operators. connection.createStatement(sql).bind(...) chains, manual row mapping with row.get(). Byte-identical on Micronaut and Spring Boot. Quarkus reactive runs Vert.x/Mutiny, not Reactor, so it gets a line-for-line mirror: same SQL constants, same parameter binding order, same result mapping — just in Mutiny’s Uni/Multi API instead of Reactor’s Mono/Flux.

The Quarkus SQL parity is enforced by diffing the SQL constants. The byte-identical check is enforced by git blob hashes:

# scripts/verify-dao-parity.sh (15 checks)
# compares blob hashes across branches for dao/ and domain/ packages
git ls-tree origin/micronaut/imperative-synth -- src/main/java/dev/tsvinc/space/dao
git ls-tree origin/springboot/imperative-synth -- src/main/java/dev/tsvinc/space/dao
# blob hashes must match

Each framework contributes exactly three things: bean wiring (@Factory / @Produces / @Configuration), its HTTP server, and its connection pool.

What Disappeared When Hibernate Left

Quarkus at JVM-256 used to crash-loop. The working theory was “metaspace floor” — Quarkus’s framework metadata was too large for 256 MB. Framework DNA. Architectural limitation. Nothing to be done.

After removing Hibernate, non-heap memory at jvm-256 peak:

FrameworkNon-heap peak
Quarkus82 MB
Micronaut84 MB
Spring Boot88 MB

The metaspace floor was Hibernate’s entity metadata and runtime bytecode generation, not Quarkus’s framework overhead. With plain SQL and no annotation scanning, all three frameworks land within 6 MB of each other.

Quarkus still crashed at jvm-256 after Hibernate was gone. The new culprit: its default Vert.x worker pool grew to 155 threads under load. At 1 MB per thread stack (the JVM default), that’s 155 MB in stacks alone — on top of 82 MB non-heap plus the actual heap. A tuning default (quarkus.vertx.worker-pool-size), not framework DNA.

What Frameworks Actually Contribute

After equalization, the only moving parts are:

HTTP server: Netty (Micronaut, Spring Boot WebFlux), Vert.x (Quarkus). On the CRUD workload — no upstream calls, no fan-out, just DB round-trips — the server barely matters. The request is a thin shell around a SQL query.

Connection pool: HikariCP (Micronaut/Spring Boot imperative), r2dbc-pool (Micronaut/Spring Boot reactive), Agroal (Quarkus imperative), vertx-pg-client (Quarkus reactive). These are invisible on the happy path. They become the entire story under cancellation pressure.

Bean wiring: A factory class. Three annotations. The cost is startup time, not request throughput.

On the CRUD workload at steady state, framework identity is a rounding error.

The Gap Nobody Warns About

Same SQL. Same DAOs. But not the same cancel semantics.

When a client timeout aborts a reactive pipeline mid-transaction:

  • Reactor (Micronaut, Spring Boot): propagates the cancellation through the pipeline, triggers rollback
  • Mutiny/Vert.x (Quarkus): cannot cancel a transaction mid-flight — the in-progress write commits

Same DAO code. Same SQL. Different guarantees. Under client timeouts, Quarkus commits abandoned writes where Micronaut and Spring Boot roll back.

This isn’t a bug — it’s a fundamental constraint of the Vert.x threading model. Vert.x operations run on the event loop and can’t be interrupted the way Reactor subscriptions can be cancelled. Your reactive framework choice determines your failure semantics, not just your throughput profile.

The pool implementation is the framework’s most consequential contribution to the runtime. Not the DI container. Not the HTTP server. The pool.

Sources