Repository navigation
Review the Java runtime and framework configuration for performance #117
Description
This issue reviews the Java runtime and the configuration of each Java framework for performance. The frameworks are spring-boot, quarkus, micronaut, helidon-se, vertx and javalin. Where a framework or the runtime documents a faster setting, the target adopts it.
The changes come from configuration, attributes and annotations, and source generation. They do not come from custom code. System.Text.Json's source generator is the model. It is a documented facility that makes the framework's own serializer faster without replacing it.
This issue is the working log. It records everything that was checked, including what was already right and what was rejected.
It is one of six reviews, one per language: #115 (.NET), #116 (Go), #117 (Java), #118 (Node), #119 (Python), #120 (Rust).
Rules
In scope:
- Settings the framework or runtime documents: configuration files, environment variables, runtime and build flags, attributes and annotations, source generators, and declarative schemas.
- A third-party serializer, when it is a well-known library and the framework documents how to integrate it. Well-known means widely used and maintained. Do not swap in a small library because it is a few percent faster. Stay on well-worn paths.
- Turning off development-mode features, such as auto-reload, debug pages, documentation routes and per-request logging.
Out of scope:
- Hand-written serialization, and custom code wherever a documented setting exists.
- Responses cached or serialized ahead of time when the endpoint does not call for it.
- Skipping work an endpoint requires, such as binding, validation or rendering.
- Dropping from the framework's API to a lower layer.
targets/python/fastapi/README.md:44-47rejects returning Starlette'sJSONResponsefrom FastAPI for this reason. - Changing what an endpoint answers.
make testdecides that. - Runtime and framework version bumps. Record a newer stable release under open questions instead.
- Settings that tie a binary to one CPU model. Record them under open questions.
- Hard-coded core or thread counts. A target gets the CPUs the harness gives it (
RB_CPUS, two by default,harness/run.py:298). A setting either reads that count or keeps the framework's default.
A runtime-level setting applies to every Java target. A difference between two targets then stays a difference between their frameworks.
Settled, and not reopened here
- Every framework uses its own facility (Use each framework's own validation facility instead of the shared validator #35, Use each framework's own view facility instead of the shared renderer #36, Use each framework's own query binding instead of the shared coercer #37, Let each framework's own ETag machinery run, and split cached into validation and response-cache families #38, Use each framework's own compression instead of the shared gzip helper in the .NET and Java targets #87). A change configures the framework's own facility. It does not add a shared helper, and it does not configure shared code that several targets call.
- Compression runs at each framework's fastest configurable level (Use each framework's own compression instead of the shared gzip helper in the .NET and Java targets #87).
- Native compilation belongs to the
container-aothost (Add acontainer-aothost that measures both builds of the frameworks that support AOT #70). The container targets stay on the JVM. - The
gcp-funcandlambda-riehosts build from the same modules throughtargets/java/Dockerfile.gcpandtargets/java/Dockerfile.lambda. They must keep building. micronaut fails the lambda-rie gate on compressed.gzip_large #98 covers micronaut's openlambda-riefailure.
Verification
No load tests. Six reviews run at once on one machine, so a timing taken here would measure the other five as well. Numbers from hosted runners are also disposable until the baremetal host is up. The A/B runs on the baremetal host after the PR is open.
Each change records:
- the documentation or source that explains why it is faster, linked
make suites-javaandmake test LANGUAGES=javapassing after it- for a change to serialization or rendering, the response size of each
json.*andtemplate.*endpoint before and after
A serializer can change whitespace, escaping or number format. The size difference is part of the result.
Each change is its own commit, so the A/B can revert any change that does not help.
How this log is kept
- The checklist below is updated as the work goes. Every item ends as changed (with the commit), already right (with the evidence), or rejected (with the reason).
- The runtime gets one comment, and each framework gets one. A comment records what was checked, the files and lines, the documentation or source links, the decision, and the commit.
- A change that contradicts a target's README or its
mechanismsentry inspec/matrix.jsonupdates them in the same commit. - Anything that needs a decision goes under open questions. It stays out of the code until it is answered.
- The work lands in one PR from
perf/java. When the PR is open, the summary below gets a table and the PR link.
What is there now
- Every target starts with
java -jar /app/server.jaroneclipse-temurin:25-jre, with no JVM options (targets/java/Dockerfile:32,targets/java/Dockerfile:38). The JVM chooses its GC and heap size from what the container shows it. - Jackson is 2.22.2, through the BOM (
targets/java/pom.xml:55). Json.MAPPERis oneObjectMapperthat every target shares (targets/java/shared/src/main/java/rb/domain/Json.java:16). javalin hands it to Javalin's Jackson integration (targets/java/javalin/src/main/java/rb/javalin/Main.java:43).- micronaut serializes through
micronaut-jackson-databind(targets/java/micronaut/pom.xml:68). It setsjackson.serialization-inclusion=ALWAYS(targets/java/micronaut/src/main/resources/application.properties:4). - quarkus uses
quarkus-rest-jackson(targets/java/quarkus/pom.xml:55). - helidon-se uses
helidon-http-media-jackson(targets/java/helidon-se/pom.xml:53). - spring-boot's
application.propertiessets onlyserver.compression.enabled=true.
Leads
These are starting points, not conclusions. Each one is checked against the framework's current documentation before anything changes.
- JVM GC and heap settings for a throughput server in a container with two CPUs, as the JDK documents them.
- The JDK's AOT cache (JEP 483 and its JDK 25 follow-ups) shortens warmup rather than steady state. It needs a training run in the image build. It goes under open questions.
- Micronaut Serialization (
micronaut-serde-jackson) is Micronaut's own serializer. It works from compile-time introspection instead of reflection. - Quarkus's build-time, reflection-free Jackson serializers.
- Jackson's Blackbird module, for targets that stay on Jackson databind. It is the Jackson project's own module.
- spring-boot: virtual threads (
spring.threads.virtual.enabled), Tomcat settings, and Spring Boot's Jackson properties. - vertx: its JSON codec and its event loop settings.
- helidon-se: its media support and server settings.
- javalin: its Jetty thread pool and its JSON mapper settings.
- A setting goes on the framework's own mapper or codec. It does not go on the shared
Json.MAPPER.
Checklist
Runtime
- JDK version and base image (record only): recorded.
eclipse-temurin:25-jre, with JDK 27 under open questions - JVM GC and heap settings: changed (e20102d). Compact object headers. The collector and heap sizes stay with ergonomics
- JVM JIT and warmup settings: already right. The tiered compiler is the default, and an AOT cache is under open questions
- Jackson version and modules in
targets/java/pom.xml: already right. 2.22.2 is the latest 2.x, and each framework decides modules - Shared code on the request path, such as
Json.MAPPER(record only): recorded. vertx and javalin serialize every JSON response through it
spring-boot
- Serializer for responses and request bodies, and how it is wired: rejected (aba9884, removed in 4c7d4e5). Afterburner was already on the mapper through the lambda-rie adapter, and it is under open questions
- Performance features the framework documents, such as source generation, compiled schemas or reflection-free paths: rejected. Spring AOT, the AOT cache, an extracted jar and lazy initialization act on startup only
- Template rendering and caching: changed (2f57efa). Thymeleaf's SpEL compiler. The template cache was already on
- The server it runs on, and that server's documented settings: changed (94ae28f). No request limit per keep-alive connection in Tomcat. Virtual threads rejected
- Logging, diagnostics and development-mode features: already right. INFO logging, no access log, no devtools or actuator
quarkus
- Serializer for responses and request bodies, and how it is wired: already right.
quarkus-rest-jackson, Quarkus REST's own Jackson integration - Performance features the framework documents, such as source generation, compiled schemas or reflection-free paths: changed (275d894). Reflection-free Jackson serializers generated at build time. JVM-mode AOT rejected as startup only
- Template rendering and caching: changed (5fa510f).
@TemplateDatagenerates a Qute value resolver forProduct. Templates were already parsed and cached at startup - The server it runs on, and that server's documented settings: changed (8211ddb).
@NonBlockingon anApplicationsubclass puts every method on the I/O thread. Native transport under open questions - Logging, diagnostics and development-mode features: already right. Prod mode, no access log, banner off
micronaut
- Serializer for responses and request bodies, and how it is wired: rejected (a691ed1, reverted in 4d18b42). Micronaut Serialization changes Micronaut's own HAL error body. Under open questions
- Performance features the framework documents, such as source generation, compiled schemas or reflection-free paths: open question. Micronaut Serialization is the reflection-free path
- Template rendering and caching: already right. Thymeleaf templates are cached by default
- The server it runs on, and that server's documented settings: already right. Controllers run on the event loop. Native transport under open questions
- Logging, diagnostics and development-mode features: already right. No access logger, leak detector off
helidon-se
- Serializer for responses and request bodies, and how it is wired: already right. Helidon's own Jackson media support and mapper
- Performance features the framework documents, such as source generation, compiled schemas or reflection-free paths: open question. Helidon's JSON binding is a preview and needs annotations on the shared records
- Template rendering and caching: already right. The Thymeleaf resolver caches templates
- The server it runs on, and that server's documented settings: open question.
tcp-no-delayrecommended and not changed after new changes stopped. Everything else keeps Helidon's defaults - Logging, diagnostics and development-mode features: already right. No access log module, log listeners only at TRACE or DEBUG
vertx
- Serializer for responses and request bodies, and how it is wired: already right.
Reply.jsondoes the same work as Vert.x's codec. The wiring is under open questions - Performance features the framework documents, such as source generation, compiled schemas or reflection-free paths: already right. Vert.x documents no build-time generation
- Template rendering and caching: already right. Handlebars templates are cached by default
- The server it runs on, and that server's documented settings: changed (470ccd5). One
HttpServerper CPU, where one event loop served everything - Logging, diagnostics and development-mode features: already right. Production mode, no
LoggerHandler
javalin
- Serializer for responses and request bodies, and how it is wired: open question.
JavalinJacksonwould drop a decode of every response. Not changed after new changes stopped - Performance features the framework documents, such as source generation, compiled schemas or reflection-free paths: already right. Javalin documents none
- Template rendering and caching: already right. mustache.java caches compiled templates
- The server it runs on, and that server's documented settings: already right. Jetty's defaults. Virtual threads rejected
- Logging, diagnostics and development-mode features: already right.
slf4j-nop, no request logger
Open questions
-
JDK 27. JDK 27 went GA on 2026-09-15. The targets run JDK 25, the current LTS. Docker Hub had no
eclipse-temurin27 JRE tag on 2026-09-19. A version bump is out of scope here. Options: stay on 25, or move to 27 once Temurin publishes it. Recommendation: stay on 25 until the baremetal A/B of this PR is done, then move in a change of its own. JDK 27 turns compact object headers on by default, so e20102d would then have no effect. -
Garbage collector. The JVM picks G1 for a two-CPU container. The GC tuning guide suggests Parallel for peak throughput when pauses do not matter, ZGC when response time is a high priority, and Serial for data sets under about 100 MB. Options: keep G1, or set
-XX:+UseParallelGC,-XX:+UseZGCor-XX:+UseSerialGCintargets/java/Dockerfile. Recommendation: keep G1 in this PR, and A/B Parallel against it on the baremetal host at every rung. The documentation cannot say which one wins on latency at fixed offered rates. -
AOT cache. JDK 25 can store loaded and linked classes and method profiles from a training run in an AOT cache (JEPs 483, 514 and 515). It shortens startup and warmup, not peak. Options: leave it out, add a training run to
targets/java/Dockerfile, or add one only to the function hosts, where the first requests are measured. Recommendation: leave it out of the container host, where every rung follows a 40 s warmup, and consider it for gcp-func and lambda-rie in an issue of its own. -
Where else the JVM option goes.
-XX:+UseCompactObjectHeadersis only intargets/java/Dockerfile.Dockerfile.gcpandDockerfile.lambdastart the JVM without it. So does the local launcher atharness/run.py:198, which means CI conformance runs without it, while theimagesjob boots every image with it. Options: leave the other launchers as they are, or give them the same option. Recommendation: leave them for this PR. If the A/B keeps the option, add it to both function hosts, so a target runs the same JVM on every host.run.pycan stay as it is, because the option does not change a response. -
Native transports. vertx, quarkus and micronaut run on Netty with its NIO transport. Netty's page says its native transports "generate less garbage, and generally improve performance" (Native transports). Each framework needs
netty-transport-native-epollor io_uring with an architecture classifier, plus its own switch:VertxOptions.setPreferNativeTransport(true),quarkus.vertx.prefer-native-transport=true, ormicronaut.netty.event-loops.default.transport. The classifier ties the jar to one CPU architecture, which is the kind of setting the issue puts here. Options: stay on NIO, add the linux-x86_64 library for the baremetal host, or add both the x86_64 and aarch64 libraries. Recommendation: A/B epoll on the baremetal host with both libraries in the jar. Vert.x falls back to NIO when the library for the machine is missing, so neither build breaks. -
Afterburner on spring-boot's container mapper.
aws-serverless-java-container-core3.0.2, which the lambda-rie adapter brings, depends onjackson-module-afterburner. Spring Boot adds every Jackson module it finds through ServiceLoader, so the container target serializes with Afterburner, and no other target has an accessor module. Afterburner cannot reach the package-private records inParametersandHeaders, and it falls back to plain Jackson for them. Options: keep it and say so in thejsonmechanism, or turn discovery off for Spring's mapper withspring.jackson.find-and-add-modules=false. Recommendation: A/Bfind-and-add-modules=falseagainst the current state, keep the faster one, and name the result in thejsonmechanism. -
Micronaut Serialization. a691ed1 moved micronaut to
micronaut-serde-jackson, and 4d18b42 moved it back. Conformance passed. Micronaut's own HAL error body changed shape:_links.selfbecame an array and_embedded.errors[].patha string. Three suite tests pin the databind shape. Serde's JMH run on Java 25 puts its runtime path, which the imported and renamed rb-shared records would take, at 409,800 serialize ops/s against databind's 394,478. Options: stay onmicronaut-jackson-databind, or switch and re-pin the three envelope shapes in the suite. Recommendation: switch. It is Micronaut's default serializer, conformance accepts its error envelope, andJson.javaalready describes micronaut as a serde target.micronaut-aws-lambda-events-serdegoes with it for lambda-rie. -
micronaut's
/__metaserializer label. It reportsjackson 2.22.2, the repo's Jackson 2 pin, whilemicronaut-jackson-databind5.1.15 runs Jackson 3.1.6. Options: reporttools.jackson.databind.cfg.PackageVersion.VERSION, which survives shading, or fix the label together with question 7. Recommendation: fix it together with question 7. It is a reporting fix, and it was left out because new changes stopped. -
helidon-se
tcp-no-delay. Helidon keeps Nagle's algorithm on by default. Its performance guide calls that "best for most cases", and it says enabling TCP_NODELAY "can improve performance" for some workloads. json.medium and json.large leave Helidon in several chunked writes, which is where Nagle can hold back a last segment. Tomcat, Jetty and Vert.x enable TCP_NODELAY by default. Options: keep the default, or setSocketOptions.builder().tcpNoDelay(true)inMain.server. Recommendation: set it and A/B it. -
Helidon JSON binding.
helidon-json-bindinggenerates serializers at compile time for@Json.Entitytypes. It is marked@Api.Preview, has no reflection fallback, and would need annotations on the rb-shared records or a helidon-se copy of the model. Options: stay on Jackson, copy the model into helidon-se, or wait until the binding leaves preview. Recommendation: stay on Jackson until it leaves preview. -
JSON wiring in javalin and vertx. Both serialize through the shared
Json.MAPPERand not their framework's codec. javalin also decodes each response into aStringbefore Javalin encodes it again (Main.java:46-65). Options: move javalin toJavalinJacksonand widenSupport.body's catch toException, move vertx toRoutingContext.json, or leave both. Recommendation: make the javalin change, because it removes a decode of every response. Moving vertx changes no work, so it belongs to the own-facility cleanup rather than this review.
Summary
PR #125 (#125) is ready for review. Its head, 4d18b42, passed every job in validate run 35445175698.
| Target | Change | Commit |
|---|---|---|
| every container target | -XX:+UseCompactObjectHeaders in targets/java/Dockerfile |
e20102d |
| spring-boot | Thymeleaf's SpEL compiler, spring.thymeleaf.enable-spring-el-compiler=true |
2f57efa |
| spring-boot | No request limit per keep-alive connection, server.tomcat.max-keep-alive-requests=-1 |
94ae28f |
| quarkus | Reflection-free Jackson serializers, generated at build time | 275d894 |
| quarkus | @NonBlocking as the default, on an Application subclass |
8211ddb |
| quarkus | @TemplateData(target = Product.class) for Qute |
5fa510f |
| vertx | One HttpServer verticle instance per CPU |
470ccd5 |
Added and then removed: Blackbird on spring-boot (aba9884, removed in 4c7d4e5) and Micronaut Serialization on micronaut (a691ed1, removed in 4d18b42).
Ian stopped new changes during the review so the branch could pass CI. The changes recommended for helidon-se and javalin are open questions 9 and 11, and they are not in the PR.
Not verified: Dockerfile.gcp and Dockerfile.lambda were not built, response sizes were not measured, and no load test was run.
Activity
ipjohnson commentedon Sep 19, 2026
Runtime
One change. Every container target now starts with
-XX:+UseCompactObjectHeaders(e20102d). The collector, the heap sizes and the JIT stay with the JVM's ergonomics. JDK 27, the collector, an AOT cache and two scope notes go under open questions.How this review is verified
Ian changed the verification rules while this review was running. This machine runs no harness, builds no image and takes no timings. A suite runs here only if its toolchain is installed locally. Conformance moves to CI.
java -versionfinds no runtime, andmvnis not on the path.make suites-javawould fall back to Docker, so it was not run here.validateworkflow on draft PR Apply the documented performance settings to the Java targets #125 checks every push. Its Java jobs build all six targets, runmake suites LANGUAGES=java, run the conformance check for all six in local mode, and build and boot each Java image.Dockerfile.gcpandDockerfile.lambdaare not built anywhere. Thevalidateworkflow does not build them, and this machine builds no image.Baseline:
validaterun 35398000457 on main at 948defc passed every Java job. That isjava target suites pass,java targets conform("6/6 targets answer spec/expected.json"), andcontainer images buildfor all six targets.This change:
validaterun 35442070028 on e20102d passed every job. All six Java images booted with the new option and answered/__meta. The vertx image reported"runtime":"java 25.0.4".JDK version and base image (record only)
eclipse-temurin:25-jre(targets/java/Dockerfile:32). It is built onmaven:3.9-eclipse-temurin-25(targets/java/Dockerfile:6), andtargets/java/pom.xml:37compiles for release 25.eclipse-temurin27 JRE tag when checked on 2026-09-19.JVM GC and heap settings
--cpus 2, or a two-core--cpuset-cpuswhereRB_SUT_CPUSis set (harness/run.py:314-330). It gets no memory limit, becauseharness/run.py:351-354passes no--memory. lambda-rie gets 1 CPU (CPUS_FOR_HOST,harness/run.py:62).ENTRYPOINTattargets/java/Dockerfile:40is nowjava -XX:+UseCompactObjectHeaders -jar /app/server.jar, so the option applies to every container target.Dockerfile.gcpandDockerfile.lambdaare unchanged, so the function hosts start the JVM as before. The local launcher atharness/run.py:198still runsjava -jarwith no options, so CI conformance runs without compact headers. Theimagesjob boots every image with them. Both points are under open questions.JVM JIT and warmup settings
spec/ladder.json,warmup). The serial hosts warm for 20,000 requests.Jackson version and modules in
targets/java/pom.xmljackson-bomis 2.22.2 (targets/java/pom.xml:55). That is the latest 2.x release on Maven Central.targets/java/spring-boot/src/main/java/rb/spring/Main.java:42-45).Json.MAPPER, so each framework's comment decides Blackbird.Shared code on the request path (record only)
Json.MAPPERis a defaultJsonMapper(targets/java/shared/src/main/java/rb/domain/Json.java:16).targets/java/vertx/src/main/java/rb/vertx/Reply.java:17) and parses request bodies with it (Reply.java:80).targets/java/javalin/src/main/java/rb/javalin/Main.java:46-65).targets/java/spring-boot/src/main/java/rb/spring/routes/Authorized.java:36).routes/Etag.javaandroutes/Cache.javain each).Main.java:43. Line 43 is a comment. The code atMain.java:46is an anonymous implementation of Javalin'sJsonMapperinterface. It callsJson.bytesand decodes the bytes into aString, and it is notJavalinJackson. The javalin comment takes this up.ipjohnson commentedon Sep 19, 2026
spring-boot
Two changes:
Blackbird went on Spring Boot's Jackson mapper in aba9884, and 4c7d4e5 took it off again. Spring Boot already registers Afterburner on that mapper, because the lambda-rie adapter depends on it, so Blackbird was never in use. Afterburner is under open questions. Virtual threads are rejected. The Spring Boot 4.1.1 BOM manages Spring Framework 7.0.9, Tomcat 11.0.24, Thymeleaf 3.1.5.RELEASE and Jackson 3.1.5 (spring-boot-dependencies-4.1.1.pom).
Verification: each commit went through the
validateworkflow on PR #125 on its own, and every job passed. The runs are 35442742461 for aba9884 (since removed), 35443066860 for 2f57efa and 35443326369 for 94ae28f. Each run built the target, ran its suite and checked its conformance. Response sizes were not measured, as the runtime comment explains.Dockerfile.lambdawas not built for spring-boot, because neither CI nor this machine builds it. Both remaining changes reach that host too, because the two properties are in the sameapplication.properties.Serializer for responses and request bodies, and how it is wired
JacksonAutoConfigurationbuilds aJsonMapper. Spring MVC'sJacksonJsonHttpMessageConverterwrites responses and reads request bodies with it. The target configures nothing on it, becauseapplication.propertieshas nospring.jackson.*key.tools.jackson.module:jackson-module-blackbird. The Spring Boot BOM imports jackson-bom 3.1.5, which manages it. Its jar namestools.jackson.module.blackbird.BlackbirdModuleinMETA-INF/services/tools.jackson.databind.JacksonModule, so the dependency alone registers it.routes/Authorized.java:36is written with the sharedJson.bytes, because the servlet filter answers before Spring MVC runs. It is outside the json and template families and was left as it is.tools.jackson.module.afterburner.ser.IntMethodPropertyWriterand its siblings serialize the records, fromjackson-module-afterburner-3.1.5.jar.aws-serverless-java-container-core3.0.2, whichaws-serverless-java-container-springboot4brings for the lambda-rie host, depends ontools.jackson.module:jackson-module-afterburner(pom). Spring Boot adds every module it finds through ServiceLoader, so the container target's mapper has run Afterburner all along. The suite log on main at 948defc has the same Afterburner lines.Parameters$One,Parameters$TwoandHeaders$Bound(IllegalAccessError), logs "Disabling Afterburner serialization" for them, and falls back to plain Jackson for those types. It optimizes the publicModelrecords.pom.xmlcomment, which called the adapter unused under the container host. Whether the container target keeps Afterburner is under open questions.Performance features the framework documents
spring.aot.enabled) is "beneficial for the startup time" and needs an AOT-processed jar (Ahead-of-Time Processing).spring.main.lazy-initializationshortens startup by moving bean creation to the first request.Template rendering and caching
spring.thymeleaf.cachedefaults to true, so each template is parsed once (Application Properties). The target does not change it.spring.thymeleaf.enable-spring-el-compilerdefaults to false. Spring Boot passes it toSpringTemplateEngine.setEnableSpringELCompiler. Thymeleaf's guide says the compiler "can speed up execution in most scenarios, but might be incompatible with specific cases when expressions in one template are reused across different data types" (Thymeleaf + Spring).SpelCompilerMode.MIXED, "in order to avoid ClassCastExceptions" (SPELVariableExpressionEvaluator.java:98-103). In Spring Framework 7.0.9 a mixed-mode expression compiles after 100 interpreted evaluations, and one that cannot compile stays interpreted (SpelExpression.java:66-69).templates/items.htmlevaluates${it.id},${it.name},${it.category},${it.priceCents}and${it.inStock}on aProductrecord for every row, and${size}and${count}once. Each expression always sees the same type.ReflectivePropertyAccessorreads a record through its accessor method. Its compiled form needs a public member of a public class, andModel.Productis both. I traced this from source and did not run it.targets/java/spring-boot/src/main/resources/application.properties.The server it runs on, and that server's documented settings
spring-boot-starter-webruns Tomcat 11.0.24.server.tomcat.max-keep-alive-requests, default 100: "When set to -1, an unlimited number of pipelined or keep-alive requests are allowed" (Application Properties, linked above). Tomcat's reference says the same ofmaxKeepAliveRequests: "If not specified, this attribute is set to 100" (HTTP Connector).gen/blend.mjs:192,keepAlive: true). Under Tomcat's default, every hundredth request on a connection closes it, and the next request on that socket waits for a new TCP connection. -1 is not a core or thread count.server.tomcat.threads.max(200),server.tomcat.max-connections(8192) andserver.tomcat.accept-count(100) keep their defaults. Changing the first would fix a thread count, which the issue rules out.spring.threads.virtual.enabled(default false) runs Tomcat's requests on virtual threads. Spring Boot warns that "applications can experience lower throughput because of 'Pinned Virtual Threads'" (Virtual Threads). JEP 444 says "Virtual threads are not faster threads", and that they help when "the workload is not CPU-bound". These handlers compute in memory and never block on I/O. Decision: rejected.Logging, diagnostics and development-mode features
server.tomcat.accesslog.enabledandspring.mvc.log-request-detailsboth default to false, and the target sets neither.Main.java:47).pom.xmlhas no devtools or actuator dependency.ipjohnson commentedon Sep 19, 2026
quarkus
Three changes:
Quarkus is 3.39.3, with
quarkus-rest,quarkus-rest-jackson,quarkus-rest-quteandquarkus-hibernate-validator. The guides linked below are read at tag 3.39.3.Verification: each commit went through the
validateworkflow on PR #125 on its own, and every job passed. The runs are 35443759358 for 275d894, 35444070423 for 8211ddb and 35444370877 for 5fa510f. Response sizes were not measured, as the runtime comment explains. quarkus runs on no function host, becausehost_exceptionsexcludes gcp-func and lambda-rie for it.Serializer for responses and request bodies, and how it is wired
quarkus-rest-jackson(targets/java/quarkus/pom.xml:55) is Quarkus REST's own Jackson integration. It writes what a resource method returns and reads the body a method takes.application.propertiesconfigured nothing on it.Json.bytes(routes/Etag.java:35,routes/Cache.java:40). The runtime comment records that.Performance features the framework documents
StdSerializerandStdDeserializerimplementations at build time for each class requiring JSON conversion. The application then processes objects returned by REST endpoints using these generated serializers and deserializers, eliminating reliance on reflection and improving performance." It also says: "This feature is introduced as a Technology Preview and is disabled by default." (Reflection-free Jackson serialization and deserialization).quarkus.rest.jackson.optimization.enable-reflection-free-serializers, fixed at build time. The only limitation the guide names is Kotlin classes.ResteasyReactiveJacksonProcessor,JacksonCodeGenerator) covers resource-method return types and the types of POST, PUT and PATCH parameters, and then their field types. It reads a record through its accessors and its canonical constructor. It supports@JsonProperty,@JsonPropertyOrderand@JsonIgnoreProperties, which are the annotationsModel.javauses. It skipsjava.*types, so theMapbodies stay on plain Jackson.@JsonPropertyOrderin Jandex field order, and Jandex does not promise declaration order. Key order could change while the byte count stays the same. The conformance check compares parsed JSON.quarkus.package.jar.aot.enabled) gives "faster startup", and it needs the aot-jar layout and a training run, which do not fit the uber-jar this target builds. The fast-jar layout "starts a little faster". Both act on startup only, which the runtime's open question on AOT caches covers.targets/java/quarkus/src/main/resources/application.properties. AOT and fast-jar are rejected.Template rendering and caching
{it.id},{it.name},{it.category},{it.priceCents}and{it.inStock}have no declared type. Qute evaluated them through its reflection-based value resolver, which has priority -1.@TemplateData: "A value resolver is automatically generated for a type annotated with@TemplateData. This allows Quarkus to avoid using reflection to access the data at runtime." Itstargetattribute is for "third-party classes not controlled by the application".Model.Productlives in rb-shared, so the annotation goes onTemplateswithtarget = Product.class.{it.id}calls the record'sid().BeanArchives.buildComputingBeanArchiveIndex, called fromBeanArchiveProcessor). An unresolved target would have logged "@templatedata declared on ... is ignored". The CI logs for 5fa510f have no such line.items.html, which is the page being rendered, so this change stops at the annotation.The server it runs on, and that server's documented settings
quarkus.http.io-threadsis documented as something that "should only be used if you want to limit" them. Kept.@Blockingand@NonBlocking"can be applied at the method, class orjakarta.ws.rs.core.Applicationlevel", and that annotating anApplicationsubclass "will set the default for every method that does not have an explicit annotation" (Execution model). The reactive architecture guide says the dispatcher decides "whether to call it on the I/O thread, avoiding thread switches, or on a worker thread" (Quarkus Reactive Architecture).TemplateInstanceis considered non-blocking" (Qute reference). No resource method blocks, since each one reads the in-memory domain. Request filters run on the method's thread, and none of them blocks either.RestApplicationcarries@NonBlockingand no@ApplicationPath, so no path moves.@RunOnVirtualThreadapplies only to blocking endpoints, and the virtual threads guide calls virtual threads for CPU-bound work "useless and counterproductive". Rejected.quarkus.rest.output-buffer-sizedefaults to 8191, and "Larger values may give slight performance increases for large responses". json.large is 131,360 bytes and goes out in chunks today. A buffer big enough to hold it would be sized to this fixture and would change that response's framing. Rejected.quarkus.vertx.prefer-native-transport=truewithnetty-transport-native-epoll) needs an architecture classifier. It goes under open questions, together with vertx and micronaut.Logging, diagnostics and development-mode features
application.properties:3).ipjohnson commentedon Sep 19, 2026
micronaut
No change stays in the branch. Micronaut Serialization went in with a691ed1 and came out with 4d18b42, because it changes the shape of Micronaut's own error body. The switch is under open questions, with the evidence below. Everything else was already right, apart from a wrong
/__metalabel and the native transport, which are both under open questions.The platform is 5.1.5. It manages core 5.1.15, serialization 3.1.1, views 6.2.0, validation 5.1.0, aws 5.1.1 and gcp 6.1.0. The docs linked below are read at those tags.
Verification:
validaterun 35444765279 on a691ed1 failedjava target suites passon three micronaut tests. Itsjava targets conformjob passed. The revert, 4d18b42, is the head of PR #125, and it passed every job invalidaterun 35445175698.Dockerfile.gcpandDockerfile.lambdawere not built, and the branch leaves micronaut as it was on main.Serializer for responses and request bodies, and how it is wired
micronaut-jackson-databind(targets/java/micronaut/pom.xml:68) withjackson.serialization-inclusion=ALWAYS(application.properties:4). In Micronaut 5.1 that module runs Jackson 3.micronaut-jackson-databind5.1.15 depends ontools.jackson.core:jackson-databind3.1.6 (pom). The issue's "What is there now" does not say that./__metareports the serializer asjackson 2.22.2, the repo's Jackson 2 pin (Main.java:19,gcp/Function.java:22,lambda/Handler.java:25). That label is wrong, and it is under open questions.SerializationJacksonFeaturein micronaut-starter v5.1.5). This target also serves gcp-func.@Serdeable, and a@JsonPropertyrename also falls back to the runtime path. The rb-shared records are imported and renamed, so they would run at about 4% over databind.micronaut-serde-jacksonandmicronaut-serde-processor. It imported the 16Modelrecords micronaut writes with@SerdeImportonMain, marked micronaut's own records@Serdeable, setmicronaut.serde.serialization.inclusion=ALWAYS, and addedmicronaut-aws-lambda-events-serdefor the lambda-rie adapter.ErrorsTests.a_body_that_is_not_json_at_all_is_refused,BodyTests.a_body_failing_three_rules_is_refusedandBodyTests.a_body_failing_one_rule_is_refused_the_same_wayreported that the envelope shape moved. Micronaut answers an unreadable body itself, in HAL. Under serde,_links.selfis an array of links instead of one link object, and_embedded.errors[].pathis a string instead of null. The inclusion setting was alreadyALWAYS, so no null was dropped. The fields changed type.client-exception/src/index.tsaccepts either shape, and the suite pins the databind one.Performance features the framework documents
jackson.bean-introspection-moduledefaults to true and uses Micronaut's introspections for@Introspectedtypes under databind. The rb-shared records are not introspected, so it does nothing for them.Template rendering and caching
micronaut.views.thymeleaf.cacheabledefaults to true, andcache-ttl-msdefaults to null. Thymeleaf keeps a parsed template "in cache until removed by LRU" when the TTL is null. The source isThymeleafViewsRendererConfigurationPropertiesin micronaut-views 6.2.0.The server it runs on, and that server's documented settings
micronaut.server.thread-selection"defaults to MANUAL thread selection since Micronaut 2.0" (core guide,reactiveServer.adocandthreadPools/atBlocking.adoc).micronaut.netty.event-loops.default.thread-core-ratio, default 1.0, so 2 threads here. The ratio reads the CPU count.num-threadswould fix a count and is ruled out.Logging, diagnostics and development-mode features
micronaut.server.netty.log-levelis unset.ipjohnson commentedon Sep 19, 2026
helidon-se
No change. Helidon SE is 4.5.4, and its docs and source are read at that tag. One setting looked worth adopting,
connection-options.tcp-no-delay. It was not made, because Ian stopped new changes during this review so the branch could pass CI. It is under open questions, together with Helidon's JSON binding.Serializer for responses and request bodies, and how it is wired
res.send(record)goes through Helidon's media context tohelidon-http-media-jackson(targets/java/helidon-se/pom.xml:53), which Helidon discovers through its service loader.JacksonSupportbuilds its own mapper:JsonMapper.builder().enable(USE_FAST_DOUBLE_PARSER).enable(USE_FAST_BIG_NUMBER_PARSER).build(), plusParameterNamesModule,Jdk8ModuleandJavaTimeModule(JacksonConfigSupport.java). So the json family already writes with Helidon's own mapper and not with the sharedJson.MAPPER.Json.bytes(routes/Etag.java:27,routes/Cache.java:42). The runtime comment records that.Performance features the framework documents
io.helidon.json:helidon-json-binding, withio.helidon.http.media:helidon-http-media-json-bindingfor the server. Its guide says: "At compile time, the processor generates efficient serializers/deserializers for classes annotated with @Json.Entity" (docs/se/json/json.md).JsonandJsonBindingcarry@Api.Preview, which Helidon defines as "supported and intended for external use, but they are still evolving". The binding has no reflection fallback, so every type it writes needs@Json.Entity. Its media support has weight 20 against Jackson's 15, so it claims every type once it is present.Template rendering and caching
resolver.setCacheable(true)(routes/Templates.java), so each template is parsed once. Thymeleaf outside Spring evaluates with OGNL and has no documented compile option.The server it runs on, and that server's documented settings
ExecutorsFactoryoffers no platform-thread option.connection-options.tcp-no-delaydefaults to false. The guide says "Default (false: Nagle's algorithm enabled) is best for most cases. But for some OS and workloads enabling TCP_NODELAY (disable Nagle's algorithm) can improve performance."write-buffer-size), and Jackson writes pieces of up to 8,000 bytes. So json.medium (8,131 bytes) and json.large (131,360 bytes) leave in several writes with chunked encoding. I read that from source and did not run it. With Nagle on, the last short segment of such a response can wait for the client's ACK.ServerConnectorand Vert.x'sHttpServerOptionsalso default it to true. Of the servers I checked, Helidon is the only one with Nagle on.backlog,write-buffer-sizeandwrite-queue-lengthunder "Depends on the workload and kernel version", and it calls TCP autotuning of the socket buffers "best for most cases". A larger write buffer would also change the framing of json.medium.read-timeout: PT0Sis described only as "less clutter on socket impl". The HTTP/1 validation flags come with "Careful with this! Can be dangerous if you turn these off." All of these keep their defaults.tcp-no-delay, which is an open question.Logging, diagnostics and development-mode features
helidon-webserver-access-logis not a dependency. The HTTP/1send-logandrecv-loglisteners default to true, and they log only at TRACE or DEBUG. Error handling'sinclude-entityandlog-all-messagesdefault to false.ipjohnson commentedon Sep 19, 2026
vertx
One change. Each CPU now gets its own
HttpServer, deployed as a verticle instance (470ccd5). Before this, one server served every request on one event loop, so vertx used one core of the two it is given. Vert.x is 5.1.8, and its docs and source are read at that tag.Verification: 470ccd5 was pushed together with the two reverts, so its check is
validaterun 35445175698 on 4d18b42, which passed every job. In that run the conformance check started vertx throughmain(), theimagesjob booted the vertx image, and the suite ran against its own server built fromMain.routerandMain.options. Response sizes were not measured, as the runtime comment explains. vertx runs on no function host.Serializer for responses and request bodies, and how it is wired
Reply.json(targets/java/vertx/src/main/java/rb/vertx/Reply.java:17). It serializes with the sharedJson.bytes, sets the content type and length, and ends the response with oneBuffer. Request bodies go throughJson.body(Reply.java:80).RoutingContext.json(Object), which encodes withJson.encodeToBuffer. With jackson-databind on the classpath,Json.CODECisDatabindCodec, a plainObjectMapperwith Vert.x's own module added. That is the same workReply.jsondoes: onewriteValueAsBytesand oneBuffer. Vert.x documents no faster setting for its codec.Performance features the framework documents
Template rendering and caching
vertx-web-templ-handlebars. The vertx-web guide says "By default caching is always enabled", unlessvertxweb.environmentorVERTXWEB_ENVIRONMENTisdev(vertx-web index.adoc). The target sets neither.The server it runs on, and that server's documented settings
main()created oneHttpServer, outside any verticle. Vert.x's TCP guide says: "The handlers of any TCP server are always executed on the same event loop thread. This means that if you are running on a server with a lot of cores, and you only have this one instance deployed then you will have at most one core utilised on your server. In order to utilise more cores of your server you will need to deploy more instances of the server." (Scaling - sharing TCP servers).main()would not help. A thread outside Vert.x always gets the same event loop (VertxImpl.stickyEventLoop, read from source).HttpServerVerticlewithDeploymentOptions.setInstances(Runtime.getRuntime().availableProcessors()). Each instance builds its router withMain.router(vertx)and its options withMain.options(), as the suite's server already does. The count reads the CPUs the JVM sees in its container, which is 2 under--cpus 2or a two-core cpuset. The Vert.x docs name no count, and their examples hard-code one.staticand wraps a synchronized map, and the serial counter is anAtomicLong.Logging, diagnostics and development-mode features
LoggerHandleris opt-in, and the target mounts none. Network activity logging is off by default.ipjohnson commentedon Sep 19, 2026
javalin
No change. Javalin is 7.2.3 on Jetty 12, and its source is read at tag
javalin-parent-7.2.3. One change looked worth making, which is to serialize throughJavalinJackson. It was not made, because Ian stopped new changes during this review so the branch could pass CI. It is under open questions.Serializer for responses and request bodies, and how it is wired
Main.java:46-65is an anonymous implementation of Javalin'sJsonMapperinterface. ItstoJsonStringserializes with the sharedJson.bytesand decodes those bytes into aString. Javalin then encodes thatStringinto the response.fromJsonStringparses with the sharedJson.MAPPER.JavalinJackson. Javalin uses it by default when jackson-databind is on the classpath: "Javalin uses Jackson as the default JSON mapper. It's a fast and feature-rich mapper" (javalin.io documentation). ItstoJsonStringcallsmapper.writeValueAsString, so the decode step goes away. Its default mapper is a plainObjectMapper()with no features changed, the same asJson.MAPPER(JavalinJackson.kt).Support.body(Support.java:17) must catchExceptioninstead ofRuntimeException.JavalinJacksonlets Jackson's checkedJsonProcessingExceptionthrough, and a malformed body would otherwise reach the 500 handler.bodyValidatoralready catches every exception (Validator.kt:43).Performance features the framework documents
Template rendering and caching
JavalinMustachecallsmustacheFactory.compile(filePath)on every render. mustache.java'sDefaultMustacheFactory.compilelooks the template up withcomputeIfAbsent, so each template compiles once. Javalin's rendering page says nothing about caching.The server it runs on, and that server's documented settings
QueuedThreadPool(250, 8, 60_000)by default. Its size is a thread count, which the issue rules out.cfg.concurrency.useVirtualThreadsdefaults to false. When set, Jetty serves requests on virtual threads. JEP 444 says "Virtual threads are not faster threads", and that they help when "the workload is not CPU-bound". These handlers compute in memory. Rejected.ServerConnectorsets TCP_NODELAY by default. Javalin always adds Jetty'sStatisticsHandlerandLowResourceMonitor, with no documented switch.Logging, diagnostics and development-mode features
slf4j-nop(pom.xml:43). Javalin'shttpRequestLoggersis empty by default, so no request is timed or logged, and dev logging is opt-in throughbundledPlugins.enableDevLogging().ipjohnson commentedon Sep 23, 2026
The rewrite replaced the targets this review worked on with ports under
frameworks/java/, written fresh. The decisions at the end of this log are in them: Javalin writes JSON with JavalinJackson, its default mapper, which settles the open question here. A review of the new ports would start from their READMEs andrb.json, so I'm closing this one. Reopen it if something here still needs doing.