Three Front Doors, One Engine
Contents
Part 22 of the Micronaut native image series. Follows Part 21, where a retry was mistranslated two ways. Assumes familiarity with reactive Java (Mono/Flux/Uni) and at least one of the three frameworks.
The experiment
Same app. Same Java 25. Same GraalVM CE 25. Same Postgres. Same Liquibase schema. Same benchmark harness. Three frameworks.
The app fans out to 28 HTTP APIs concurrently, computes risk scores across seismic, atmospheric, and space weather data, and writes one row to Postgres. Six endpoints. The heaviest hits all 28 APIs in four parallel blocks. Built on Micronaut 5.0.0 with reactive R2DBC. Ported to Spring Boot 4.1 and Quarkus 3.33.
The question was “how much code survives the port?” The answer was “most of it.” But the interesting part turned out to be something else entirely: three classes of bugs that made the first benchmark numbers garbage.
Three annotations, same shape
Micronaut:
@Client("science")
@Retryable(attempts = "2", delay = "500ms")
public interface SolarClient {
@Get("/api/v1/solar-flares")
Mono<SolarFlareResponse> solarFlares();
}
Spring Boot 4.1:
@HttpExchange
public interface SolarClient {
@GetExchange("/api/v1/solar-flares")
Mono<SolarFlareResponse> solarFlares();
}
Quarkus 3.33:
@RegisterRestClient(configKey = "science")
@Retry(maxRetries = 2, delay = 500, delayUnit = ChronoUnit.MILLIS)
public interface SolarClient {
@GET @Path("/api/v1/solar-flares")
Uni<SolarFlareResponse> solarFlares();
}
Two of three return Mono<T>. Quarkus returns Uni<T>. That one-word difference propagates through the entire service layer.
Spring’s missing half
Micronaut’s @Client carries the service ID, base URL, and retry policy in two annotations. Spring’s @HttpExchange carries the path prefix. That’s it.
The rest moves to a config class:
@Configuration(proxyBeanMethods = false)
@RegisterReflectionForBinding({
ApodResponse.class, AqiResponse.class,
// ...26 more DTOs
})
public class HttpClientsConfig {
public HttpClientsConfig(ClientProperties props) {
this.science = web(props.getScience(), 5000, 3000,
Retry.fixedDelay(1, Duration.ofMillis(500)));
}
@Bean SolarClient solarClient() {
return proxy(SolarClient.class, science);
}
// ...9 more @Bean methods
}
@RegisterReflectionForBinding is the interesting part. Spring’s AOT infers reflection hints for controller return types automatically. SolarFlareResponse in a @RestController method? Registered. SolarFlareResponse deserialized through WebClient? Not visible to the AOT processor. Skip those 28 classes and the native binary silently returns empty JSON.
Quarkus needed zero reflection hints. Its build-time augmentation walks @RegisterRestClient interfaces, sees the return types, follows the Jackson annotations, registers everything.
Micronaut sits between: a tracing agent captures most targets, but Liquibase setter methods resolved through java.beans.Introspector are invisible to the agent. Those get hand-added to a 1,912-entry reachability-metadata.json.
| Framework | Client DTOs | Liquibase | Manual entries |
|---|---|---|---|
| Quarkus 3.33 | automatic | automatic | 0 |
| Spring Boot 4.1 | @RegisterReflectionForBinding | automatic | 28 |
| Micronaut 5.0 | tracing agent | hand-augmented | ~1,912 |
Reactor to Mutiny
The Spring port was a search-and-replace. @Singleton to @Service. @Controller to @RestController. The Reactor chains – every Mono.zip(), every .doOnNext(), every .onErrorReturn() – copied verbatim. WebFlux speaks Reactor natively.
Quarkus speaks Mutiny. Every operator has an equivalent. None share a name.
| Reactor | Mutiny |
|---|---|
Mono.zip(...) | Uni.combine().all().unis(...).asTuple() |
.doOnNext(fn) | .onItem().invoke(fn) |
.onErrorReturn(v) | .onFailure().recoverWithItem(v) |
.flatMap(fn) | .onItem().transformToUni(fn) |
.timeout(d) | .ifNoItem().after(d).fail() |
repo.save(e).thenReturn(r) | entity.persist().replaceWith(r) |
The translation is mechanical but total. What survives unchanged: ServiceConstants.java. Byte-identical across all three ports. Risk-scoring math, cross-correlation logic, 28 EMPTY_* fallback constants. Plain Java doesn’t care which reactive library called it.
The numbers lied
First benchmark run. Same scenario: native-synth-256mb. Same k6 profile ramping to 300 VUs. Same Go mock server. The analysis script reported:
Spring Boot: 820 HTTP requests served. Quarkus: 12,563.
Spring had 15x less throughput than Quarkus? That seemed wrong. It was wrong – but not the way you’d guess.
Bug 1: the metric that resets
The analysis script computed “requests served” as last-minus-first of the Prometheus http_server_requests_seconds_count counter. Prometheus counters are monotonic. Unless the process restarts, in which case they reset to zero.
Spring’s native binary was crash-looping. Each restart zeroed the counter. The script saw the last value (820) minus the first from the current process lifetime, not the total across all restarts. k6 – the load generator, running outside the container – counted 63,000+ completed requests.
Lesson: trust the load generator, not an app-side counter that resets on OOM-kill.
Bug 2: Spring native OOM at 256 MB
GraalVM’s Serial GC defaults max heap to ~80% of the container. At 256 MB, that’s ~205 MB of heap. Under the 300-VU peak, heap plus Netty direct buffers plus native image overhead breached 256 MB. OOM-kill. Restart. OOM-kill. The harness uses restart: on-failure, so the container came back every time – and crash-looped through the entire benchmark.
Micronaut native survives 256 MB unaided. Spring’s native image is heavier.
The fix: -XX:MaxHeapSize=128m as a command-line arg to the native binary. JAVA_TOOL_OPTIONS doesn’t reach native images. You pass heap limits directly to the binary, not through environment variables designed for the JVM.
After: zero restarts.
Bug 3: Quarkus at 5x slower (and why)
With the metric bug fixed and Spring no longer crash-looping, the next run looked like this: Quarkus averaged 6.4 seconds per request. p95: 16 seconds. CPU at ~1%.
Two stacked bottlenecks.
@WithTransaction held a DB connection across the entire fan-out. The Quarkus service method had @WithTransaction on the method. That meant a Hibernate-Reactive session was acquired before the 28 HTTP calls and held through all of them. Fourteen pooled connections pinned for multi-second requests. The DB pool queue hit 287.
Fix: scope the transaction to the persist only:
// Before: @WithTransaction on the method
// After: transaction wraps only the insert
Panache.withTransaction(() -> entity.persist())
.replaceWith(response);
Then the REST client connection pool throttled the fan-out. With the DB bottleneck removed, latency dropped – but not enough. The Vert.x REST client defaults to 50 connections per host. Twenty-four “science” clients sharing one pool means the 28-way fan-out queued behind a 50-connection ceiling. Connection-acquire wait: 5 seconds.
Fix: quarkus.rest-client.science.connection-pool-size=200.
Before both fixes: 18.8 req/s, 33% errors. After: 100.3 req/s, 0.002% errors.
Two ways to get retry wrong
A code review of both ports found a symmetric retry bug, in opposite directions.
Quarkus over-retried. MicroProfile @Retry(maxRetries = 2) means 2 retries + 1 initial = 3 total calls. Micronaut @Retryable(attempts = "2") means 2 total. The naive 1:1 port gave every Quarkus client one extra attempt per failure.
Spring under-retried. Spring WebClient emits HTTP 5xx as a normal onNext signal, not an error. The retryWhen operator only fires on transport errors – connection resets, timeouts. A mock server returning 500? That’s a successful HTTP response. Micronaut retries 5xx by default.
Both fixed to match the Micronaut reference before the final comparison.
The shared blind spot
Both ports’ tests mocked every service. The real persist() / repository.save() – the actual INSERT into Postgres – was never exercised by CI in either port. Added a real-DB integration test to each. The kind of test you don’t know you’re missing until you deploy.
The actual comparison
After fixing the harness, the OOM, the transaction scope, the connection pool, and the retry semantics – a fair comparison. Native GraalVM images, native-synth-256mb scenario, k6 as ground truth.
| Spring Boot 4.1 | Quarkus 3.33 | |
|---|---|---|
| Throughput | 97.2 req/s | 100.3 req/s |
| p95 latency | 2,096 ms | 2,683 ms |
| Errors | 0.000% | 0.002% |
| Peak RSS | 188 MB | 213 MB |
| Avg RSS | 105 MB | 115 MB |
| Restarts | 0 | 0 |
| Idle RSS | 73 MB | 54 MB |
| Startup | 0.58 s | 0.475 s |
Throughput parity. Both gated by the ~350 ms external mock-API latency, not the concurrency model. Same lesson as the previous post – reactive vs. imperative didn’t matter on Micronaut, and now framework choice doesn’t matter either, once the bugs are fixed. The bottleneck is always the network call.
Quarkus idles lighter (54 vs. 73 MB) and starts faster (0.475 vs. 0.58 s). Spring uses less memory under load (188 vs. 213 MB peak). Pick whichever axis you care about.
The unchanged 80%
Twenty-eight DTO records. Response records. ServiceConstants.java. The Liquibase changelog. The Go mock server. The k6 scripts. The report generators.
Porting a reactive app across three frameworks is mostly porting the framework’s opinion about how to declare an HTTP client and where to put the retry policy. The business logic – risk scores, cross-correlations, fan-out structure – is plain Java that doesn’t know which framework is running it.
The dangerous part isn’t the port. It’s assuming the port is correct because the tests pass and the app starts. The first benchmark numbers were fiction. It took fixing three bugs in the harness, two in Quarkus, one in Spring, and one missing test in both before the comparison meant anything.
Sources
- Micronaut Declarative HTTP Client –
@Clientinterface conventions - Spring HTTP Interface Clients –
@HttpExchangeover WebClient - Quarkus REST Client – MicroProfile
@RegisterRestClient - SmallRye Mutiny – Combining Unis –
Uni.combine().all().unis().asTuple() - GraalVM Reachability Metadata – reflection hints for native image
- Spring AOT Hints –
@RegisterReflectionForBindingfor native compilation - GraalVM Native Image Memory Management – Serial GC heap sizing,
-XX:MaxHeapSize - MicroProfile Fault Tolerance @Retry –
maxRetriessemantics (retries, not total attempts) - Quarkus Vert.x REST Client connection pool –
connection-pool-sizetuning