Skip to content

Review the Java runtime and framework configuration for performance #117

Closed

Description

@ipjohnson

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-47 rejects returning Starlette's JSONResponse from FastAPI for this reason.
  • Changing what an endpoint answers. make test decides 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

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-java and make test LANGUAGES=java passing after it
  • for a change to serialization or rendering, the response size of each json.* and template.* 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 mechanisms entry in spec/matrix.json updates 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.jar on eclipse-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.MAPPER is one ObjectMapper that 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 sets jackson.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.properties sets only server.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). @TemplateData generates a Qute value resolver for Product. Templates were already parsed and cached at startup
  • The server it runs on, and that server's documented settings: changed (8211ddb). @NonBlocking on an Application subclass 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-delay recommended 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.json does 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 HttpServer per 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. JavalinJackson would 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

  1. JDK 27. JDK 27 went GA on 2026-09-15. The targets run JDK 25, the current LTS. Docker Hub had no eclipse-temurin 27 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.

  2. 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:+UseZGC or -XX:+UseSerialGC in targets/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.

  3. 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.

  4. Where else the JVM option goes. -XX:+UseCompactObjectHeaders is only in targets/java/Dockerfile. Dockerfile.gcp and Dockerfile.lambda start the JVM without it. So does the local launcher at harness/run.py:198, which means CI conformance runs without it, while the images job 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.py can stay as it is, because the option does not change a response.

  5. 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-epoll or io_uring with an architecture classifier, plus its own switch: VertxOptions.setPreferNativeTransport(true), quarkus.vertx.prefer-native-transport=true, or micronaut.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.

  6. Afterburner on spring-boot's container mapper. aws-serverless-java-container-core 3.0.2, which the lambda-rie adapter brings, depends on jackson-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 in Parameters and Headers, and it falls back to plain Jackson for them. Options: keep it and say so in the json mechanism, or turn discovery off for Spring's mapper with spring.jackson.find-and-add-modules=false. Recommendation: A/B find-and-add-modules=false against the current state, keep the faster one, and name the result in the json mechanism.

  7. 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.self became an array and _embedded.errors[].path a 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 on micronaut-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, and Json.java already describes micronaut as a serde target. micronaut-aws-lambda-events-serde goes with it for lambda-rie.

  8. micronaut's /__meta serializer label. It reports jackson 2.22.2, the repo's Jackson 2 pin, while micronaut-jackson-databind 5.1.15 runs Jackson 3.1.6. Options: report tools.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.

  9. 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 set SocketOptions.builder().tcpNoDelay(true) in Main.server. Recommendation: set it and A/B it.

  10. Helidon JSON binding. helidon-json-binding generates serializers at compile time for @Json.Entity types. 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.

  11. JSON wiring in javalin and vertx. Both serialize through the shared Json.MAPPER and not their framework's codec. javalin also decodes each response into a String before Javalin encodes it again (Main.java:46-65). Options: move javalin to JavalinJackson and widen Support.body's catch to Exception, move vertx to RoutingContext.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

  1. ipjohnson commented on Sep 19, 2026

    @ipjohnson
    OwnerAuthor

    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.

    • This machine has no JDK and no Maven. java -version finds no runtime, and mvn is not on the path. make suites-java would fall back to Docker, so it was not run here.
    • The validate workflow on draft PR Apply the documented performance settings to the Java targets #125 checks every push. Its Java jobs build all six targets, run make suites LANGUAGES=java, run the conformance check for all six in local mode, and build and boot each Java image.
    • Response sizes are not measured. A quick local boot needs a JDK, and this machine has none.
    • Dockerfile.gcp and Dockerfile.lambda are not built anywhere. The validate workflow does not build them, and this machine builds no image.

    Baseline: validate run 35398000457 on main at 948defc passed every Java job. That is java target suites pass, java targets conform ("6/6 targets answer spec/expected.json"), and container images build for all six targets.

    This change: validate run 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)

    • The runtime image is eclipse-temurin:25-jre (targets/java/Dockerfile:32). It is built on maven:3.9-eclipse-temurin-25 (targets/java/Dockerfile:6), and targets/java/pom.xml:37 compiles for release 25.
    • JDK 25 went GA on 2025-09-16. JDK 26 went GA on 2026-03-17, and JDK 27 on 2026-09-15 (openjdk.org/projects/jdk).
    • Docker Hub had no eclipse-temurin 27 JRE tag when checked on 2026-09-19.
    • Decision: recorded. JDK 27 goes under open questions.

    JVM GC and heap settings

    • A container target gets 2 CPUs. That is --cpus 2, or a two-core --cpuset-cpus where RB_SUT_CPUS is set (harness/run.py:314-330). It gets no memory limit, because harness/run.py:351-354 passes no --memory. lambda-rie gets 1 CPU (CPUS_FOR_HOST, harness/run.py:62).
    • With no options, the JVM picks G1 on a server-class machine and Serial otherwise. A server-class machine has two or more processors and at least 1792 MB. The initial heap is 1/64 of physical memory and the maximum is 1/4 (GC tuning guide: Ergonomics). The container targets therefore run G1, with a maximum heap of a quarter of the machine's memory.
    • The guide says to let the VM select the collector unless pause-time requirements are strict. It suggests Parallel for peak throughput when pauses do not matter, G1 when response time matters more than throughput, ZGC when response time is a high priority, and Serial for data sets under about 100 MB (Available Collectors). The harness reports latency percentiles at fixed offered rates. None of these collectors is documented as faster for that, so the collector goes under open questions for the A/B.
    • The guide advises against choosing a maximum heap unless the default is too small. The heap sizes stay with ergonomics.
    • Compact object headers shrink an object header from 96 to 64 bits. JEP 519 made them a product feature in JDK 25. It reports that SPECjbb2015 "uses 22% less heap space and 8% less CPU time", that collections drop by 15% with both G1 and Parallel, and that "a highly parallel JSON parser benchmark runs in 10% less time". JEP 534 makes them the default in JDK 27.
    • JEP 450 turns them off for a heap over 8 TB without ZGC, and when JVMCI is enabled. Neither applies here.
    • Decision: changed in e20102d. The ENTRYPOINT at targets/java/Dockerfile:40 is now java -XX:+UseCompactObjectHeaders -jar /app/server.jar, so the option applies to every container target.
    • Dockerfile.gcp and Dockerfile.lambda are unchanged, so the function hosts start the JVM as before. The local launcher at harness/run.py:198 still runs java -jar with no options, so CI conformance runs without compact headers. The images job boots every image with them. Both points are under open questions.

    JVM JIT and warmup settings

    • The JVM runs the tiered compiler, C1 and C2, by default (Ergonomics, linked above). I found no JIT option that the JDK documents as raising steady-state throughput over that default.
    • Every rung follows a warmup of 40 s at 1000 rps (spec/ladder.json, warmup). The serial hosts warm for 20,000 requests.
    • An AOT cache improves startup (JEP 483, JEP 514) and warmup (JEP 515). None of the three JEPs claims a gain at peak. A cache needs a training run in the image build.
    • Decision: already right. The AOT cache goes under open questions.

    Jackson version and modules in targets/java/pom.xml

    • jackson-bom is 2.22.2 (targets/java/pom.xml:55). That is the latest 2.x release on Maven Central.
    • spring-boot is on Jackson 3 from the Spring Boot BOM instead (targets/java/spring-boot/src/main/java/rb/spring/Main.java:42-45).
    • No mapper in any target registers a Jackson module. Blackbird (README) replaces reflective getter and setter calls with generated lambdas. The README reports "up to 20% better than vanilla Jackson in some cases". The same BOM manages it at 2.22.2.
    • By the issue's rule, a module goes on each framework's own mapper and not on Json.MAPPER, so each framework's comment decides Blackbird.
    • Decision: already right. The version is current, and module registration belongs to each framework.

    Shared code on the request path (record only)

    • Json.MAPPER is a default JsonMapper (targets/java/shared/src/main/java/rb/domain/Json.java:16).
    • vertx serializes every JSON response through it (targets/java/vertx/src/main/java/rb/vertx/Reply.java:17) and parses request bodies with it (Reply.java:80).
    • javalin serializes and parses through it (targets/java/javalin/src/main/java/rb/javalin/Main.java:46-65).
    • spring-boot writes the 403 body with it (targets/java/spring-boot/src/main/java/rb/spring/routes/Authorized.java:36).
    • helidon-se, micronaut, quarkus and vertx serialize the bytes the ETag and cache families hash or store with it (routes/Etag.java and routes/Cache.java in each).
    • The issue says javalin hands the shared mapper to Javalin's Jackson integration, citing Main.java:43. Line 43 is a comment. The code at Main.java:46 is an anonymous implementation of Javalin's JsonMapper interface. It calls Json.bytes and decodes the bytes into a String, and it is not JavalinJackson. The javalin comment takes this up.
    • Decision: recorded.
  2. ipjohnson commented on Sep 19, 2026

    @ipjohnson
    OwnerAuthor

    spring-boot

    Two changes:

    • Thymeleaf compiles the template's SpEL (2f57efa).
    • Tomcat no longer closes a keep-alive connection after its hundredth request (94ae28f).

    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 validate workflow 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.lambda was 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 same application.properties.

    Serializer for responses and request bodies, and how it is wired

    • Spring Boot's JacksonAutoConfiguration builds a JsonMapper. Spring MVC's JacksonJsonHttpMessageConverter writes responses and reads request bodies with it. The target configures nothing on it, because application.properties has no spring.jackson.* key.
    • Spring Boot documents two ways to add a module: "Any beans of type JacksonModule are automatically registered with the auto-configured JsonMapper.Builder", and "Any modules that participate in the Java ServiceLoader mechanism are, by default, found and added to the auto-configured JsonMapper.Builder" (How-to: Customize the Jackson JsonMapper).
    • Blackbird for Jackson 3 is tools.jackson.module:jackson-module-blackbird. The Spring Boot BOM imports jackson-bom 3.1.5, which manages it. Its jar names tools.jackson.module.blackbird.BlackbirdModule in META-INF/services/tools.jackson.databind.JacksonModule, so the dependency alone registers it.
    • The Blackbird README says it calls getters, setters and constructors through generated lambdas instead of reflection. It reports "up to 20% better than vanilla Jackson in some cases", measured on OpenJDK 11 and 12.
    • The README says nothing about records, and the model here is records. Record accessors are getters to Jackson. An open Blackbird PR (jackson-modules-base#359) says the current Blackbird "never accelerated the record or builder paths". That refers to building a record from a request body. The A/B decides whether this change stays.
    • Micronaut Serialization's JMH run on Java 25 measured Blackbird at 391,556 serialize ops/s against plain Jackson databind's 394,478, and at 3,165 ns against 3,282 ns per deserialize (why.adoc). That run found no serialization gain on Java 25 and a 4% deserialization gain. This change stays in a commit of its own so the A/B can drop it.
    • Blackbird changes how accessors are called and not what is written, so the response bytes should not change.
    • The 403 body in routes/Authorized.java:36 is written with the shared Json.bytes, because the servlet filter answers before Spring MVC runs. It is outside the json and template families and was left as it is.
    • The suite log for aba9884 shows why it did nothing. tools.jackson.module.afterburner.ser.IntMethodPropertyWriter and its siblings serialize the records, from jackson-module-afterburner-3.1.5.jar. aws-serverless-java-container-core 3.0.2, which aws-serverless-java-container-springboot4 brings for the lambda-rie host, depends on tools.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.
    • Afterburner fails to reach the package-private records Parameters$One, Parameters$Two and Headers$Bound (IllegalAccessError), logs "Disabling Afterburner serialization" for them, and falls back to plain Jackson for those types. It optimizes the public Model records.
    • Blackbird and Afterburner both replace the same property writers. The writers in use were Afterburner's, so Blackbird was not in use.
    • Decision: rejected. aba9884 added Blackbird, and 4c7d4e5 removed it. 4c7d4e5 also corrects the pom.xml comment, 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 on the JVM (spring.aot.enabled) is "beneficial for the startup time" and needs an AOT-processed jar (Ahead-of-Time Processing).
    • Spring Boot's AOT cache support "can help reduce the startup time and memory footprint" (AOT Cache). The runtime's open question on AOT caches covers it.
    • An extracted jar changes startup only: "After startup, you should not expect any differences in execution time" (Efficient Deployments).
    • spring.main.lazy-initialization shortens startup by moving bean creation to the first request.
    • Decision: rejected. All four act on startup, and every rung follows a 40 s warmup. The SpEL compiler below is the steady-state feature.

    Template rendering and caching

    • spring.thymeleaf.cache defaults to true, so each template is parsed once (Application Properties). The target does not change it.
    • spring.thymeleaf.enable-spring-el-compiler defaults to false. Spring Boot passes it to SpringTemplateEngine.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).
    • thymeleaf-spring6 3.1.5 runs the compiler in 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.html evaluates ${it.id}, ${it.name}, ${it.category}, ${it.priceCents} and ${it.inStock} on a Product record for every row, and ${size} and ${count} once. Each expression always sees the same type. ReflectivePropertyAccessor reads a record through its accessor method. Its compiled form needs a public member of a public class, and Model.Product is both. I traced this from source and did not run it.
    • Decision: changed in 2f57efa. The property is in targets/java/spring-boot/src/main/resources/application.properties.

    The server it runs on, and that server's documented settings

    • spring-boot-starter-web runs Tomcat 11.0.24.
    • By default Tomcat closes a keep-alive connection after its hundredth request. The Spring Boot property is 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 of maxKeepAliveRequests: "If not specified, this attribute is set to 100" (HTTP Connector).
    • The generator keeps its connections alive (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.
    • Decision: changed in 94ae28f.
    • server.tomcat.threads.max (200), server.tomcat.max-connections (8192) and server.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

    • Spring Boot logs ERROR, WARN and INFO by default. server.tomcat.accesslog.enabled and spring.mvc.log-request-details both default to false, and the target sets neither.
    • The banner is off (Main.java:47). pom.xml has no devtools or actuator dependency.
    • Decision: already right.
  3. ipjohnson commented on Sep 19, 2026

    @ipjohnson
    OwnerAuthor

    quarkus

    Three changes:

    • Quarkus REST generates the Jackson serializers and deserializers at build time (275d894).
    • Every resource method runs on the I/O thread (8211ddb).
    • Qute gets a generated value resolver for the template's rows (5fa510f).

    Quarkus is 3.39.3, with quarkus-rest, quarkus-rest-jackson, quarkus-rest-qute and quarkus-hibernate-validator. The guides linked below are read at tag 3.39.3.

    Verification: each commit went through the validate workflow 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, because host_exceptions excludes 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.properties configured nothing on it.
    • The ETag and cache families hash and store bytes from the shared Json.bytes (routes/Etag.java:35, routes/Cache.java:40). The runtime comment records that.
    • Decision: already right. The build-time serializers are under the next item.

    Performance features the framework documents

    • Quarkus REST can generate a Jackson serializer and deserializer for each type at build time. The guide says: "When enabled, Quarkus generates StdSerializer and StdDeserializer implementations 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).
    • The property is quarkus.rest.jackson.optimization.enable-reflection-free-serializers, fixed at build time. The only limitation the guide names is Kotlin classes.
    • The generator's source at 3.39.3 (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, @JsonPropertyOrder and @JsonIgnoreProperties, which are the annotations Model.java uses. It skips java.* types, so the Map bodies stay on plain Jackson.
    • A generated serializer writes a class without @JsonPropertyOrder in 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.
    • JVM-mode AOT (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.
    • Decision: changed in 275d894. The property is in targets/java/quarkus/src/main/resources/application.properties. AOT and fast-jar are rejected.

    Template rendering and caching

    • Qute parses and caches every template when the application starts: "The template is automatically parsed and cached" (Qute reference). That part is already right.
    • The row expressions {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.
    • The Qute reference, under @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." Its target attribute is for "third-party classes not controlled by the application". Model.Product lives in rb-shared, so the annotation goes on Templates with target = Product.class.
    • The generated resolver matches a property by the exact method name as well as by getter name (ValueResolverGenerator.java), so {it.id} calls the record's id().
    • Qute looks the target up in the bean archive index, and that index indexes a missing class on demand (BeanArchives.buildComputingBeanArchiveIndex, called from BeanArchiveProcessor). An unresolved target would have logged "@templatedata declared on ... is ignored". The CI logs for 5fa510f have no such line.
    • The Qute reference calls type-safe templates "the preferred solution" for native executables. They need a parameter declaration in items.html, which is the page being rendered, so this change stops at the annotation.
    • Decision: changed in 5fa510f.

    The server it runs on, and that server's documented settings

    • Quarkus REST runs on Quarkus's Vert.x HTTP server. By default it gets as many event loops as CPUs, with a minimum of 2, so 2 here. quarkus.http.io-threads is documented as something that "should only be used if you want to limit" them. Kept.
    • The guide on the execution model says a method that returns a plain object is "run on a worker thread". It also says @Blocking and @NonBlocking "can be applied at the method, class or jakarta.ws.rs.core.Application level", and that annotating an Application subclass "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).
    • Before this change, every family except template.* switched to a worker thread for each request. template.* was already on the I/O thread, because "a resource method that returns TemplateInstance is 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.
    • Decision: changed in 8211ddb. RestApplication carries @NonBlocking and no @ApplicationPath, so no path moves.
    • @RunOnVirtualThread applies only to blocking endpoints, and the virtual threads guide calls virtual threads for CPU-bound work "useless and counterproductive". Rejected.
    • quarkus.rest.output-buffer-size defaults 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.
    • A native transport (quarkus.vertx.prefer-native-transport=true with netty-transport-native-epoll) needs an architecture classifier. It goes under open questions, together with vertx and micronaut.

    Logging, diagnostics and development-mode features

    • The uber-jar runs in prod mode. The access log is off by default, the root log level is INFO, and the banner is off (application.properties:3).
    • Decision: already right.
  4. ipjohnson commented on Sep 19, 2026

    @ipjohnson
    OwnerAuthor

    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 /__meta label 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: validate run 35444765279 on a691ed1 failed java target suites pass on three micronaut tests. Its java targets conform job passed. The revert, 4d18b42, is the head of PR #125, and it passed every job in validate run 35445175698. Dockerfile.gcp and Dockerfile.lambda were not built, and the branch leaves micronaut as it was on main.

    Serializer for responses and request bodies, and how it is wired

    • micronaut serializes through micronaut-jackson-databind (targets/java/micronaut/pom.xml:68) with jackson.serialization-inclusion=ALWAYS (application.properties:4). In Micronaut 5.1 that module runs Jackson 3. micronaut-jackson-databind 5.1.15 depends on tools.jackson.core:jackson-databind 3.1.6 (pom). The issue's "What is there now" does not say that. /__meta reports the serializer as jackson 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.
    • Micronaut Serialization is Micronaut's own serializer. Its guide says it works from "build-time Bean Introspections that do not use reflection" (introduction). Micronaut Launch selects it by default, except when a Google Cloud Function feature is selected, in which case Launch adds jackson-databind (SerializationJacksonFeature in micronaut-starter v5.1.5). This target also serves gcp-func.
    • Its JMH run on Java 25 measured 394,478 serialize ops/s for Jackson Databind, 527,497 for generated serdes and 409,800 for the runtime path (why.adoc). Serdes are only generated for classes in the module that declare @Serdeable, and a @JsonProperty rename also falls back to the runtime path. The rb-shared records are imported and renamed, so they would run at about 4% over databind.
    • a691ed1 swapped in micronaut-serde-jackson and micronaut-serde-processor. It imported the 16 Model records micronaut writes with @SerdeImport on Main, marked micronaut's own records @Serdeable, set micronaut.serde.serialization.inclusion=ALWAYS, and added micronaut-aws-lambda-events-serde for the lambda-rie adapter.
    • Result: conformance passed, and three suite tests failed. ErrorsTests.a_body_that_is_not_json_at_all_is_refused, BodyTests.a_body_failing_three_rules_is_refused and BodyTests.a_body_failing_one_rule_is_refused_the_same_way reported that the envelope shape moved. Micronaut answers an unreadable body itself, in HAL. Under serde, _links.self is an array of links instead of one link object, and _embedded.errors[].path is a string instead of null. The inclusion setting was already ALWAYS, so no null was dropped. The fields changed type. client-exception/src/index.ts accepts either shape, and the suite pins the databind one.
    • Decision: rejected for this PR. 4d18b42 reverts a691ed1, because changing what an endpoint answers needs a decision first.

    Performance features the framework documents

    • Micronaut Serialization is the reflection-free path, covered above.
    • jackson.bean-introspection-module defaults to true and uses Micronaut's introspections for @Introspected types under databind. The rb-shared records are not introspected, so it does nothing for them.
    • Decision: open question, together with the serializer.

    Template rendering and caching

    • micronaut.views.thymeleaf.cacheable defaults to true, and cache-ttl-ms defaults to null. Thymeleaf keeps a parsed template "in cache until removed by LRU" when the TTL is null. The source is ThymeleafViewsRendererConfigurationProperties in micronaut-views 6.2.0.
    • Decision: already right.

    The server it runs on, and that server's documented settings

    • The Netty server runs controllers on the event loop by default. The guide says "Micronaut executes code defined in @controller beans in the same thread as the request thread (an Event Loop thread)", and micronaut.server.thread-selection "defaults to MANUAL thread selection since Micronaut 2.0" (core guide, reactiveServer.adoc and threadPools/atBlocking.adoc).
    • Event loops come from micronaut.netty.event-loops.default.thread-core-ratio, default 1.0, so 2 threads here. The ratio reads the CPU count. num-threads would fix a count and is ruled out.
    • Micronaut's server configuration guide says native transports "generate less garbage, and generally improve performance". They need an architecture classifier, so they are under open question 5 with vertx and quarkus.
    • Decision: already right.

    Logging, diagnostics and development-mode features

    • The access logger is off, the leak detector is disabled by default, and micronaut.server.netty.log-level is unset.
    • Decision: already right.
  5. ipjohnson commented on Sep 19, 2026

    @ipjohnson
    OwnerAuthor

    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 to helidon-http-media-jackson (targets/java/helidon-se/pom.xml:53), which Helidon discovers through its service loader. JacksonSupport builds its own mapper: JsonMapper.builder().enable(USE_FAST_DOUBLE_PARSER).enable(USE_FAST_BIG_NUMBER_PARSER).build(), plus ParameterNamesModule, Jdk8Module and JavaTimeModule (JacksonConfigSupport.java). So the json family already writes with Helidon's own mapper and not with the shared Json.MAPPER.
    • The ETag and cache families hash and store bytes from the shared Json.bytes (routes/Etag.java:27, routes/Cache.java:42). The runtime comment records that.
    • Blackbird would need a mapper built in code to replace Helidon's defaults. The Java 25 JMH run cited in the spring-boot comment shows no serialization gain for it. Rejected.
    • Decision: already right.

    Performance features the framework documents

    • Helidon 4.5 ships a compile-time JSON binding: io.helidon.json:helidon-json-binding, with io.helidon.http.media:helidon-http-media-json-binding for 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).
    • Json and JsonBinding carry @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.
    • The records live in rb-shared. Adopting the binding means annotating shared code or copying the model into helidon-se.
    • Decision: open question.

    Template rendering and caching

    • The target renders Thymeleaf itself, with resolver.setCacheable(true) (routes/Templates.java), so each template is parsed once. Thymeleaf outside Spring evaluates with OGNL and has no documented compile option.
    • Decision: already right.

    The server it runs on, and that server's documented settings

    • Helidon's performance guide says "Helidon WebServer is in large part self tuning. It uses default values that will satisfy most use cases, and with the adoption of Java virtual threads there is no longer a need to tune pools of platform threads" (Performance Tuning). Each request runs on a virtual thread, and ExecutorsFactory offers no platform-thread option.
    • connection-options.tcp-no-delay defaults 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."
    • Helidon writes through a 4,096-byte buffer (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.
    • Tomcat sets TCP_NODELAY by default, and its reference says it "improves performance under most circumstances" (HTTP Connector). Jetty's ServerConnector and Vert.x's HttpServerOptions also default it to true. Of the servers I checked, Helidon is the only one with Nagle on.
    • My recommendation is to set it to true for the A/B. It was not changed, because new changes stopped. It is under open questions.
    • The guide puts backlog, write-buffer-size and write-queue-length under "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: PT0S is 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.
    • Decision: already right, except tcp-no-delay, which is an open question.

    Logging, diagnostics and development-mode features

    • There is no access log, because helidon-webserver-access-log is not a dependency. The HTTP/1 send-log and recv-log listeners default to true, and they log only at TRACE or DEBUG. Error handling's include-entity and log-all-messages default to false.
    • Decision: already right.
  6. ipjohnson commented on Sep 19, 2026

    @ipjohnson
    OwnerAuthor

    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 validate run 35445175698 on 4d18b42, which passed every job. In that run the conformance check started vertx through main(), the images job booted the vertx image, and the suite ran against its own server built from Main.router and Main.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

    • Every JSON response goes through Reply.json (targets/java/vertx/src/main/java/rb/vertx/Reply.java:17). It serializes with the shared Json.bytes, sets the content type and length, and ends the response with one Buffer. Request bodies go through Json.body (Reply.java:80).
    • Vert.x's own call is RoutingContext.json(Object), which encodes with Json.encodeToBuffer. With jackson-databind on the classpath, Json.CODEC is DatabindCodec, a plain ObjectMapper with Vert.x's own module added. That is the same work Reply.json does: one writeValueAsBytes and one Buffer. Vert.x documents no faster setting for its codec.
    • Decision: already right for performance. vertx and javalin serialize through the shared mapper and not their framework's codec. That is under open questions.

    Performance features the framework documents

    • Vert.x documents no build-time generation for JSON or templates.
    • Decision: already right.

    Template rendering and caching

    • Handlebars renders through vertx-web-templ-handlebars. The vertx-web guide says "By default caching is always enabled", unless vertxweb.environment or VERTXWEB_ENVIRONMENT is dev (vertx-web index.adoc). The target sets neither.
    • Decision: already right.

    The server it runs on, and that server's documented settings

    • main() created one HttpServer, 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).
    • The HTTP guide says "When several HTTP servers listen on the same port, vert.x orchestrates the request handling using a round-robin strategy", so "the servers can scale over available cores" (TCP Server sharing).
    • Several servers created from main() would not help. A thread outside Vert.x always gets the same event loop (VertxImpl.stickyEventLoop, read from source).
    • 470ccd5 deploys HttpServerVerticle with DeploymentOptions.setInstances(Runtime.getRuntime().availableProcessors()). Each instance builds its router with Main.router(vertx) and its options with Main.options(), as the suite's server already does. The count reads the CPUs the JVM sees in its container, which is 2 under --cpus 2 or a two-core cpuset. The Vert.x docs name no count, and their examples hard-code one.
    • The event-loop pool keeps Vert.x's default of twice the CPU count, so each instance gets an event loop of its own.
    • The state the instances share is safe on two event loops. The cache store is static and wraps a synchronized map, and the serial counter is an AtomicLong.
    • TCP_NODELAY is on by default. TCP fast open, cork and quick ack work only with the native transport, which is open question 5.
    • Decision: changed in 470ccd5.

    Logging, diagnostics and development-mode features

    • The vertx-web guide says "Vert.x Web by default operates in production mode". LoggerHandler is opt-in, and the target mounts none. Network activity logging is off by default.
    • Decision: already right.
  7. ipjohnson commented on Sep 19, 2026

    @ipjohnson
    OwnerAuthor

    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 through JavalinJackson. 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-65 is an anonymous implementation of Javalin's JsonMapper interface. Its toJsonString serializes with the shared Json.bytes and decodes those bytes into a String. Javalin then encodes that String into the response. fromJsonString parses with the shared Json.MAPPER.
    • Javalin's own mapper is 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). Its toJsonString calls mapper.writeValueAsString, so the decode step goes away. Its default mapper is a plain ObjectMapper() with no features changed, the same as Json.MAPPER (JavalinJackson.kt).
    • Removing the anonymous mapper also means Support.body (Support.java:17) must catch Exception instead of RuntimeException. JavalinJackson lets Jackson's checked JsonProcessingException through, and a malformed body would otherwise reach the 500 handler. bodyValidator already catches every exception (Validator.kt:43).
    • My recommendation is to make both edits in one commit. They are not made, because new changes stopped.
    • Decision: open question.

    Performance features the framework documents

    • Javalin documents no build-time generation or reflection-free path.
    • Decision: already right.

    Template rendering and caching

    • JavalinMustache calls mustacheFactory.compile(filePath) on every render. mustache.java's DefaultMustacheFactory.compile looks the template up with computeIfAbsent, so each template compiles once. Javalin's rendering page says nothing about caching.
    • Decision: already right.

    The server it runs on, and that server's documented settings

    • Jetty runs on QueuedThreadPool(250, 8, 60_000) by default. Its size is a thread count, which the issue rules out.
    • cfg.concurrency.useVirtualThreads defaults 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.
    • Jetty's ServerConnector sets TCP_NODELAY by default. Javalin always adds Jetty's StatisticsHandler and LowResourceMonitor, with no documented switch.
    • Decision: already right.

    Logging, diagnostics and development-mode features

    • The target logs through slf4j-nop (pom.xml:43). Javalin's httpRequestLoggers is empty by default, so no request is timed or logged, and dev logging is opt-in through bundledPlugins.enableDevLogging().
    • Decision: already right.
  8. ipjohnson commented on Sep 23, 2026

    @ipjohnson
    OwnerAuthor

    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 and rb.json, so I'm closing this one. Reopen it if something here still needs doing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions