Faster JVM Startup with Quarkus and Project Leyden
A measured deployment option for teams that need faster container startup while keeping JVM diagnostics, agents, and compatibility.
After an AOT build, the Quarkus application directory contains one extra file: app.aot. The application still runs on HotSpot. The JVM uses this file to reuse loaded and linked classes plus method profiles from an earlier training run. Those profiles show which code paths the training workload used.
AOT means ahead of time. In JDK 25, HotSpot prepares JVM data before the production process starts. The application remains a normal JAR. Its just-in-time (JIT) compiler still optimizes code while the service runs. This AOT build does not create a native executable or compile the full application to machine code.
Why add the extra build step? A Quarkus fast JAR already starts quickly, but some services need more. Autoscaled APIs, short-running workers, and containers that move between nodes all pay for startup repeatedly. A reusable AOT cache can reduce that time while keeping the JVM tools and behavior the team already knows.
We will build a small shipment-tracking API and run it against PostgreSQL. Then we will train a Leyden cache through a packaged integration test, verify that HotSpot really loads it, and build an AOT-enhanced Podman image.
Versions and prerequisites
The example uses these versions:
Quarkus 3.37.2
JDK 25
PostgreSQL 18.4
Maven 3.9.16 from the generated wrapper
Podman 5 or later
Quarkus 3.37.2 is the current feature release in the Quarkus release stream. Quarkus 3.33 is the current LTS stream. We use the feature release here because it contains the current AOT tooling.
JDK 25 is the baseline for this tutorial. Quarkus selects the Leyden path automatically on HotSpot 25 and newer. On older HotSpot releases, Quarkus uses AppCDS, the earlier class-data sharing mechanism.
The JDK work started with JEP 483. A JEP is a Java Enhancement Proposal. JEP 514 and JEP 515 added more cache creation and runtime support. Project Leyden covers more work than the JDK 25 cache. This article focuses on the part we can build and run today.
You need a HotSpot-based JDK 25, the Quarkus CLI, curl, and Podman. Check the installed versions first:
java -version
quarkus --version
podman versionOn macOS or Windows, start the Podman machine:
podman machine startCreate the project
First, create an empty Maven application or start from my Github repository. We need extensions for REST, persistence, database migrations, health checks, and Podman images:
quarkus create app -B \
-P io.quarkus.platform:quarkus-bom:3.37.2 \
--maven \
--java=25 \
--no-code \
--extensions='rest-jackson,hibernate-orm-panache,jdbc-postgresql,flyway,smallrye-health,container-image-podman' \
com.themainthread:swiftship-tracking
cd swiftship-trackingThe generated build targets Java 25 and includes src/main/docker/Dockerfile.jvm. With Quarkus 3.37.2, that Dockerfile uses the UBI 9 OpenJDK 25 runtime image. Keep the build and runtime JDKs aligned because HotSpot checks the cache against its runtime environment.
The application exposes four endpoints:
GET /api/shipmentsGET /api/shipments/{trackingNumber}GET /api/shipments/summaryGET /q/health/ready
Create the database migration
Flyway creates the schema and inserts three shipments. Add src/main/resources/db/migration/V1__create_shipments.sql:
CREATE TABLE shipments (
tracking_number VARCHAR(24) PRIMARY KEY,
destination VARCHAR(120) NOT NULL,
current_status VARCHAR(32) NOT NULL,
current_location VARCHAR(120) NOT NULL,
estimated_delivery DATE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
version BIGINT NOT NULL DEFAULT 0
);
INSERT INTO shipments (
tracking_number,
destination,
current_status,
current_location,
estimated_delivery,
updated_at,
version
) VALUES
('SWIFT-1001', 'Berlin, Germany', 'IN_TRANSIT', 'Leipzig Hub', '2026-07-16', '2026-07-14T06:30:00Z', 0),
('SWIFT-1002', 'Paris, France', 'OUT_FOR_DELIVERY', 'Paris Depot', '2026-07-14', '2026-07-14T07:15:00Z', 0),
('SWIFT-1003', 'Warsaw, Poland', 'DELIVERED', 'Warsaw, Poland', '2026-07-13', '2026-07-13T15:42:00Z', 0);Create src/main/resources/application.properties:
quarkus.application.name=swiftship-tracking
quarkus.datasource.db-kind=postgresql
quarkus.datasource.jdbc.min-size=2
quarkus.datasource.jdbc.max-size=12
quarkus.datasource.jdbc.acquisition-timeout=3s
%dev,test.quarkus.datasource.devservices.image-name=docker.io/library/postgres:18.4-alpine3.24
%dev,test.quarkus.datasource.devservices.db-name=swiftship
%dev,test.quarkus.datasource.devservices.username=swiftship
%dev,test.quarkus.datasource.devservices.password=swiftship
quarkus.flyway.migrate-at-start=true
quarkus.hibernate-orm.schema-management.strategy=validate
quarkus.hibernate-orm.log.sql=false
quarkus.container-image.group=themainthread
quarkus.container-image.name=swiftship-tracking
quarkus.container-image.labels."org.opencontainers.image.source"=https://github.com/myfear/the-main-thread/swiftship-tracking
quarkus.container-image.labels."org.opencontainers.image.title"=SwiftShip TrackingIn dev and test modes, Quarkus Dev Services starts an isolated PostgreSQL container. We leave the production URL out of the file. The packaged application reads its database connection from environment variables when we start it later.
The PostgreSQL project lists PostgreSQL 18 as the current major release and 18.4 as the current minor release. We use the full image name so Podman knows which registry to use.
Build the shipment API
Create src/main/java/com/themainthread/swiftship/ShipmentStatus.java:
package com.themainthread.swiftship;
public enum ShipmentStatus {
IN_TRANSIT,
OUT_FOR_DELIVERY,
DELIVERED
}Create Shipment.java in the same package:
package com.themainthread.swiftship;
import java.time.Instant;
import java.time.LocalDate;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
@Entity
@Table(name = "shipments")
public class Shipment extends PanacheEntityBase {
@Id
@Column(name = "tracking_number", length = 24, nullable = false)
public String trackingNumber;
@Column(length = 120, nullable = false)
public String destination;
@Enumerated(EnumType.STRING)
@Column(name = "current_status", length = 32, nullable = false)
public ShipmentStatus currentStatus;
@Column(name = "current_location", length = 120, nullable = false)
public String currentLocation;
@Column(name = "estimated_delivery", nullable = false)
public LocalDate estimatedDelivery;
@Column(name = "updated_at", nullable = false)
public Instant updatedAt;
@Version
public long version;
protected Shipment() {
}
}The carrier’s tracking number is the entity ID. Panache uses a numeric ID by default, so we extend PanacheRepositoryBase<Shipment, String> here. Create ShipmentRepository.java:
package com.themainthread.swiftship;
import java.util.List;
import jakarta.enterprise.context.ApplicationScoped;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
@ApplicationScoped
public class ShipmentRepository implements PanacheRepositoryBase<Shipment, String> {
public List<Shipment> listByTrackingNumber() {
return find("order by trackingNumber").list();
}
}The API should not return the persistence entity directly. We use response records for that boundary. Create ShipmentView.java:
package com.themainthread.swiftship;
import java.time.Instant;
import java.time.LocalDate;
public record ShipmentView(
String trackingNumber,
String destination,
ShipmentStatus status,
String currentLocation,
LocalDate estimatedDelivery,
Instant updatedAt) {
static ShipmentView from(Shipment shipment) {
return new ShipmentView(
shipment.trackingNumber,
shipment.destination,
shipment.currentStatus,
shipment.currentLocation,
shipment.estimatedDelivery,
shipment.updatedAt);
}
}The summary response carries the total count and one count for each status. Create ShipmentSummary.java:
package com.themainthread.swiftship;
import java.util.Map;
public record ShipmentSummary(long total, Map<ShipmentStatus, Long> byStatus) {
}The service converts entities into API records and calculates the status counts. Create ShipmentService.java:
package com.themainthread.swiftship;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class ShipmentService {
private final ShipmentRepository shipmentRepository;
public ShipmentService(ShipmentRepository shipmentRepository) {
this.shipmentRepository = shipmentRepository;
}
public List<ShipmentView> list() {
List<ShipmentView> result = new ArrayList<>();
for (Shipment shipment : shipmentRepository.listByTrackingNumber()) {
result.add(ShipmentView.from(shipment));
}
return List.copyOf(result);
}
public Optional<ShipmentView> find(String trackingNumber) {
return shipmentRepository.findByIdOptional(trackingNumber).map(ShipmentView::from);
}
public ShipmentSummary summary() {
Map<ShipmentStatus, Long> counts = new EnumMap<>(ShipmentStatus.class);
for (ShipmentStatus shipmentStatus : ShipmentStatus.values()) {
counts.put(shipmentStatus, 0L);
}
List<Shipment> shipments = shipmentRepository.listAll();
for (Shipment shipment : shipments) {
counts.compute(shipment.currentStatus, (status, count) -> count + 1);
}
return new ShipmentSummary(shipments.size(), Map.copyOf(counts));
}
}The resource maps those service methods to HTTP. Create ShipmentResource.java:
package com.themainthread.swiftship;
import java.util.List;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/api/shipments")
@Produces(MediaType.APPLICATION_JSON)
public class ShipmentResource {
private final ShipmentService shipmentService;
public ShipmentResource(ShipmentService shipmentService) {
this.shipmentService = shipmentService;
}
@GET
public List<ShipmentView> list() {
return shipmentService.list();
}
@GET
@Path("/summary")
public ShipmentSummary summary() {
return shipmentService.summary();
}
@GET
@Path("/{trackingNumber}")
public ShipmentView find(@PathParam("trackingNumber") String trackingNumber) {
return shipmentService.find(trackingNumber)
.orElseThrow(() -> new NotFoundException("Unknown tracking number: " + trackingNumber));
}
}Each endpoint uses real Quarkus components. Startup initializes Quarkus REST, Jackson, CDI, Hibernate ORM, Flyway, the PostgreSQL driver, and health checks. The training request will use all of them.
Test the running application
Create src/test/java/com/themainthread/swiftship/ShipmentResourceTest.java:
package com.themainthread.swiftship;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasSize;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
@QuarkusTest
class ShipmentResourceTest {
@Test
void listsSeededShipments() {
given()
.when().get("/api/shipments")
.then()
.statusCode(200)
.body("$", hasSize(3))
.body("[0].trackingNumber", equalTo("SWIFT-1001"))
.body("[2].status", equalTo("DELIVERED"));
}
@Test
void returnsShipmentByTrackingNumber() {
given()
.when().get("/api/shipments/SWIFT-1002")
.then()
.statusCode(200)
.body("destination", equalTo("Paris, France"))
.body("status", equalTo("OUT_FOR_DELIVERY"))
.body("currentLocation", equalTo("Paris Depot"));
}
@Test
void returnsNotFoundForUnknownTrackingNumber() {
given()
.when().get("/api/shipments/SWIFT-9999")
.then()
.statusCode(404);
}
@Test
void summarizesShipmentStatuses() {
given()
.when().get("/api/shipments/summary")
.then()
.statusCode(200)
.body("total", equalTo(3))
.body("byStatus.IN_TRANSIT", equalTo(1))
.body("byStatus.OUT_FOR_DELIVERY", equalTo(1))
.body("byStatus.DELIVERED", equalTo(1));
}
@Test
void reportsDatabaseReadiness() {
given()
.when().get("/q/health/ready")
.then()
.statusCode(200)
.body("status", equalTo("UP"));
}
}Run the tests:
./mvnw testDev Services starts PostgreSQL, and Flyway applies the migration. The five tests cover the API and database readiness.
Now start dev mode and inspect one response:
./mvnw quarkus:devIn another terminal:
curl -s http://localhost:8080/api/shipments/SWIFT-1002The response is:
{
"currentLocation": "Paris Depot",
"destination": "Paris, France",
"estimatedDelivery": "2026-07-14",
"status": "OUT_FOR_DELIVERY",
"trackingNumber": "SWIFT-1002",
"updatedAt": "2026-07-14T07:15:00Z"
}Stop dev mode before the packaged build so it does not keep the HTTP port or test containers busy.
Define the AOT training workload
The AOT cache records what the application loads and exercises during training. If we call only the readiness endpoint, the cache misses JSON serialization and most repository code. Running the full regression suite creates a different problem. It can add test-only behavior, rare administration paths, and extra cache data that startup does not need.
One short packaged integration test gives us a better training workload. It covers the requests SwiftShip needs directly after startup. Maven Failsafe discovers classes ending in IT under src/test/java. Create src/test/java/com/themainthread/swiftship/ShipmentResourceIT.java:
package com.themainthread.swiftship;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasSize;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusIntegrationTest;
@QuarkusIntegrationTest
class ShipmentResourceIT {
@Test
void exercisesRepresentativeStartupWorkload() {
given()
.when().get("/q/health/ready")
.then()
.statusCode(200)
.body("status", equalTo("UP"));
given()
.when().get("/api/shipments")
.then()
.statusCode(200)
.body("$", hasSize(3))
.body("[0].trackingNumber", equalTo("SWIFT-1001"));
given()
.when().get("/api/shipments/SWIFT-1002")
.then()
.statusCode(200)
.body("status", equalTo("OUT_FOR_DELIVERY"));
given()
.when().get("/api/shipments/summary")
.then()
.statusCode(200)
.body("total", equalTo(3))
.body("byStatus.DELIVERED", equalTo(1));
}
}Run the packaged test once before enabling AOT:
./mvnw verify -DskipITs=falseCheck that Maven Failsafe executes ShipmentResourceIT:
[INFO] Running com.themainthread.swiftship.ShipmentResourceIT
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESSThe class name decides which Maven test plugin owns the test. Surefire runs ShipmentResourceTest during test and ignores the *IT class. Failsafe runs ShipmentResourceIT during verify when skipITs is false. This keeps the fast unit-test loop separate from the packaged training workload.
Measure a normal fast JAR first
Keep one PostgreSQL instance alive for the whole benchmark. Starting a new database for each sample would mix PostgreSQL startup, image pulls, and Dev Services lifecycle work into the application result.
Create scripts/postgres.sh:
#!/usr/bin/env bash
set -euo pipefail
readonly CONTAINER_NAME="swiftship-postgres"
readonly IMAGE="docker.io/library/postgres:18.4-alpine3.24"
readonly VOLUME_NAME="swiftship-postgres-data"
start_database() {
if podman container exists "${CONTAINER_NAME}"; then
podman start "${CONTAINER_NAME}" >/dev/null
else
podman run --detach \
--name "${CONTAINER_NAME}" \
--publish 5432:5432 \
--env POSTGRES_DB=swiftship \
--env POSTGRES_USER=swiftship \
--env POSTGRES_PASSWORD=swiftship \
--health-cmd='pg_isready -U swiftship -d swiftship' \
--health-interval=1s \
--health-timeout=3s \
--health-retries=30 \
--volume "${VOLUME_NAME}:/var/lib/postgresql" \
"${IMAGE}" >/dev/null
fi
for attempt in {1..30}; do
if [[ "$(podman inspect --format '{{.State.Health.Status}}' "${CONTAINER_NAME}")" == "healthy" ]]; then
echo "PostgreSQL is ready on localhost:5432"
return
fi
sleep 1
done
podman logs "${CONTAINER_NAME}"
echo "PostgreSQL did not become healthy within 30 seconds" >&2
exit 1
}
case "${1:-start}" in
start)
start_database
;;
stop)
podman stop "${CONTAINER_NAME}" >/dev/null
echo "PostgreSQL stopped"
;;
status)
podman inspect --format '{{.State.Status}} (health: {{.State.Health.Status}})' "${CONTAINER_NAME}"
;;
*)
echo "Usage: $0 {start|stop|status}" >&2
exit 2
;;
esacMake the script executable and start PostgreSQL:
chmod +x scripts/postgres.sh
./scripts/postgres.sh startThe sample includes scripts/StartupBenchmark.java. Java’s source-file launcher runs it without a separate compile step. For each sample, the helper starts a new JVM on port 18080 and waits until /q/health/ready returns 200. Then it stops the process. It leaves one priming run out of the result and reports the median and p95. The p95 is the value that 95 percent of the measured runs stay below.
In AOT mode, the helper adds -XX:AOTMode=on. This is strict mode: HotSpot stops if it cannot load the cache. A normal JVM fallback cannot appear in the AOT numbers by accident.
Build the normal fast JAR and measure 20 starts:
./mvnw clean package \
-Dquarkus.package.jar.aot.enabled=false
java scripts/StartupBenchmark.java fast 20On my Apple Silicon development machine with Temurin 25, I got this result for the normal fast JAR:
Mode: fast, measured runs: 20
Priming the database and filesystem cache; this run is excluded.
run 01: 1004.1 ms
...
run 20: 1019.9 ms
median: 1022.4 ms
p95: 1051.5 msThese numbers belong to one machine and one workload. Your result will be different. Benchmarking is an art. I am not looking at absolute numbers here but at how they stand in relation to each other. We keep the JDK, application, and database fixed. Every sample starts a new JVM, and the timer stops at HTTP readiness. A log line is easier to measure, but it does not tell us that the service can handle a request.
Build the Leyden AOT cache
Quarkus creates the cache while it runs the packaged integration test. Use this command:
./mvnw clean verify \
-Dquarkus.package.jar.aot.enabled=true \
-DskipITs=false-DskipITs=false enables the Failsafe training test. The generated POM skips packaged integration tests by default, which is sensible for a normal local build and wrong for cache training. We also use clean because the benchmark switches between fast-JAR and AOT-JAR packaging in the same workspace.
BUILD SUCCESS only tells us that Maven completed its lifecycle. Check the cache file too:
test -f target/quarkus-app/app.aot
ls -lh target/quarkus-app/app.aotThe cache for this application was 97 MB:
-rw-r--r-- 1 user staff 97M Jul 14 10:21 target/quarkus-app/app.aotThe rest of the fast-JAR layout stays the same:
target/quarkus-app/
├── app/
├── app.aot
├── lib/
├── quarkus/
└── quarkus-run.jarquarkus-run.jar remains the launcher. HotSpot reads app.aot during startup.
If you use Gradle
The equivalent Gradle build needs one extra safeguard when the workspace has already produced a normal fast JAR:
./gradlew build quarkusIntTest \
--rerun-tasks \
-Dquarkus.package.jar.aot.enabled=trueI first ran that command without --rerun-tasks. Gradle considered quarkusIntTest up to date, reported a successful build, and produced no app.aot. The AOT system property did not invalidate every task involved in the earlier build.
Keep --rerun-tasks in a Gradle AOT job, especially when CI reuses a workspace or Gradle state. Then check build/quarkus-app/app.aot explicitly. The rest of this article uses Maven, where clean verify gives each packaging mode an empty output directory and runs the Failsafe training test again.
Prove that HotSpot accepts the cache
By default, HotSpot can continue when the cache is missing or unusable. That behavior keeps a service available, but it can hide a broken AOT build. For verification, run the packaged application in strict AOT mode:
cd target/quarkus-app
QUARKUS_DATASOURCE_JDBC_URL=jdbc:postgresql://localhost:5432/swiftship \
QUARKUS_DATASOURCE_USERNAME=swiftship \
QUARKUS_DATASOURCE_PASSWORD=swiftship \
java -XX:AOTMode=on \
-XX:AOTCache=app.aot \
-Xlog:aot \
-jar quarkus-run.jarThe startup log must contain this line:
[info][aot] Opened AOT cache app.aot.-XX:AOTMode=on turns an unusable cache into a startup error. I use strict mode in CI and benchmarks because those runs should fail when the cache fails.
Production has a different trade-off. You may prefer automatic mode so the service still starts after a cache mismatch. Keep -Xlog:aot enabled or available in diagnostics, because you still need to see which path HotSpot used. The JDK 25 java launcher documents these settings under AOT cache options.
My JDK logged one warning about a generated adapter that it could not link to code in the AOT cache. Strict mode still opened the cache, and the application passed readiness. Verify the Opened AOT cache line and call the application endpoint. Warnings alone do not tell us whether the cache loaded.
Stop the process and return to the project root:
cd ../..From the project root, run the strict benchmark:
java scripts/StartupBenchmark.java aot 20On the same machine, I got:
Mode: aot, measured runs: 20
Priming the database and filesystem cache; this run is excluded.
run 01: 382.6 ms
...
run 20: 395.0 ms
median: 380.3 ms
p95: 398.6 msFor this workload, the median readiness time fell by 62.8 percent. That is enough for me to test AOT in the real deployment environment. A larger application, a different CPU, a cold node filesystem, or another training workload will produce different numbers.
Build the cache inside the target container environment
An AOT cache only works with the environment that created it. HotSpot checks the application class path and module graph, JDK release, operating system, and CPU architecture. The cache we recorded on macOS cannot go into a Linux image.
Quarkus handles this through the Maven build when AOT and container-image creation are both enabled. It builds the regular JVM image and runs the packaged integration test against it. This trains the cache inside the target environment. After that, Quarkus creates an -aot image with the cache.
Build both images:
./mvnw clean verify \
-Dquarkus.package.jar.aot.enabled=true \
-Dquarkus.container-image.build=true \
-DskipITs=falseQuarkus creates this AOT image:
localhost/themainthread/swiftship-tracking:1.0.0-SNAPSHOT-aotThe normal image was 489 MB. The AOT image was 589 MB, so the cache and its image layer added 100 MB. We get a faster startup, but the build takes longer and the registry stores more data.
Run the AOT image against the persistent database:
podman run --rm --name swiftship-aot \
--publish 8080:8080 \
--env QUARKUS_DATASOURCE_JDBC_URL=jdbc:postgresql://host.containers.internal:5432/swiftship \
--env QUARKUS_DATASOURCE_USERNAME=swiftship \
--env QUARKUS_DATASOURCE_PASSWORD=swiftship \
localhost/themainthread/swiftship-tracking:1.0.0-SNAPSHOT-aotIn another terminal, check readiness and one application endpoint:
curl -s http://localhost:8080/q/health/ready
curl -s http://localhost:8080/api/shipments/summaryThe summary response is:
{
"total": 3,
"byStatus": {
"IN_TRANSIT": 1,
"OUT_FOR_DELIVERY": 1,
"DELIVERED": 1
}
}The generated AOT image sets JAVA_TOOL_OPTIONS=-XX:AOTCache=app.aot. For a one-off strict verification, override it:
podman run --rm --name swiftship-aot-check \
--publish 8080:8080 \
--env JAVA_TOOL_OPTIONS='-XX:AOTMode=on -XX:AOTCache=app.aot -Xlog:aot' \
--env QUARKUS_DATASOURCE_JDBC_URL=jdbc:postgresql://host.containers.internal:5432/swiftship \
--env QUARKUS_DATASOURCE_USERNAME=swiftship \
--env QUARKUS_DATASOURCE_PASSWORD=swiftship \
localhost/themainthread/swiftship-tracking:1.0.0-SNAPSHOT-aotThe image opened app.aot, used AOT-linked classes, passed readiness, and served the shipment summary. Quarkus reported 0.275 seconds for startup inside the container. That log value helps with diagnosis, but it measures something different from the host benchmark. The host timer starts with the process and stops at HTTP readiness.
Rebuild the cache with the application
Treat app.aot like a platform-specific build artifact. Rebuild it when any of these change:
the application JARs or dependency graph
the JDK vendor build or release
the operating system base image
the CPU architecture
JVM options that change class loading or instrumentation
If the cache is missing or rejected, automatic mode continues as a normal JVM process. The service starts, but you still ship the larger image and get none of the AOT startup gain. Before publishing an image, make CI check that the cache exists and that strict mode accepts it.
Build and train a separate cache for linux/amd64 and linux/arm64. A multi-architecture image can still contain both platforms, but each platform entry needs its own cache.
Test Java agents as part of this workflow. An observability or profiling agent can load extra classes and transform existing ones. When possible, use the production agent during training and verify the image with -XX:AOTMode=on. If the service must start even after a cache mismatch, use automatic mode in production and alert when the expected AOT log signal is missing.
The training test needs the same review as other production code. Update it when startup traffic changes. Keep it deterministic and short, but cover the paths used directly after readiness. For SwiftShip, those paths are health, database access, REST routing, JSON serialization, an indexed lookup, and the summary query.
Measure startup consistently
Startup numbers change quickly when one part of the test changes. I use these rules for every variant:
Build the normal and AOT variants from the same revision with the same JDK.
Use
-XX:AOTMode=onfor AOT measurements.Fail before measuring if
app.aotis missing.Keep PostgreSQL running across samples.
Launch a new JVM for every sample.
Wait for readiness, then exercise a representative request if that is part of the service-level objective.
Record enough samples to see variance and report more than the fastest run.
Keep host, CPU allocation, memory limit, and container storage conditions fixed.
Measure again in the actual deployment environment.
Warm filesystem pages help both variants. The helper therefore calls its first run a priming run and leaves it out of the result. It does not simulate a new machine. If you need cold-node numbers, measure the full node or VM lifecycle as well.
These results only cover startup. HotSpot continues to profile and JIT-compile the running application. The training profile can also change its early optimization decisions. Measure steady-state throughput and latency with the production workload because startup-to-readiness numbers cannot answer that question.
When a native executable still fits better
An AOT-enhanced JAR keeps the JVM runtime, JIT, diagnostic tools, observability tools, and Java-agent support. It also keeps the JVM memory footprint. The build must train and store an extra cache for every target.
A Quarkus native executable uses a different deployment model. It can fit better when the service has startup or resident-memory targets that the AOT JAR cannot meet. Build and measure it as a separate experiment. Calling the Leyden cache “almost native” only makes the decision harder to explain later.
./mvnw clean package -Dnative \
-Dquarkus.native.remote-container-build=true \
-Dquarkus.native.container-runtime=podman \
-Dquarkus.container-image.build=true \
-Dquarkus.container-image.tag=nativeI validated this command with a Podman machine on macOS. Remote-container mode copies the build inputs into the VM. This avoids host UID mapping problems with mounted build directories. On Linux with a local Podman socket, replace quarkus.native.remote-container-build=true with quarkus.native.container-build=true.
The generated Maven native profile enables native-image and disables JAR output, so the two package types do not conflict. My build used Mandrel for JDK 25. It produced an 80 MB executable and a 194 MB native container image.
I keep those sizes out of the startup comparison because the helper launches JVM artifacts. A fair native comparison needs its own process launcher and memory measurements.
Choose the format from the service target:
Use a normal fast JAR when startup already meets the target.
Use a Leyden AOT JAR when JVM compatibility matters and startup work is measurable.
Use a native executable when the stricter startup or memory target justifies its separate build and compatibility model.
For SwiftShip, the JDK 25 AOT cache changed median readiness from 1022.4 ms to 380.3 ms on my machine. The service remains a normal Quarkus JVM application. I would use this option when the same gain appears in the target environment and CI can train, verify, and rebuild the cache for every platform.
The Quarkus AOT guide documents the current task names and configuration. The Quarkus Podman guide covers the container setup.


