Discover the real-world performance differences between Quarkus Native Image and Quarkus JVM. Compare build times, Docker image sizes, startup times, and memory usage with actual measurements from a production-ready application. Make informed decisions about which deployment option suits your use case.
Introduction
Quarkus offers two primary deployment options: Native Image (compiled ahead-of-time with GraalVM) and Traditional JVM (running on the Java Virtual Machine). Both have their strengths, but choosing the right one depends on your specific requirements.
This article presents a comprehensive, real-world comparison using the same Quarkus application built and deployed in both modes. We'll measure:
- Build Time: How long it takes to compile and package
- Docker Image Size: Final container image size
- Startup Time: Application startup time (from initialization to ready state, measured by Quarkus)
- Memory Usage: At startup and under load
- Build Artifact Size: Native executable vs JAR file
Methodology
All measurements were taken using:
- Application: User Management System (Quarkus 3.15.1 + Java 21)
- Database: MariaDB 12
- Container Runtime: Docker
- Startup Time Measurement: Quarkus application logs (internal measurement)
- Other Measurements: Docker stats and build tools
- Test Environment: Windows 11, Docker Desktop, 8GB RAM allocated to Docker
The application includes:
- REST API with JAX-RS
- Hibernate ORM Panache for data access
- OpenAPI/Swagger documentation
- Database migrations with Flyway
Build Time Comparison
Native Image Build
Building a Quarkus Native Image involves ahead-of-time compilation, which is computationally intensive:
cd quarkus
./mvnw -Pnative -Dquarkus.native.container-build=true clean package
Results:
- Build Time: ~210 seconds (3.5 minutes)
- Process: Compiles Java bytecode to native machine code
- Requirements: GraalVM native-image compiler (runs in Docker container)
JVM Build
Building for JVM is a standard Java compilation process:
cd quarkus
./mvnw -Pjvm clean package
Results:
- Build Time: ~15 seconds
- Process: Compiles Java to bytecode, packages into JAR
- Requirements: Standard JDK
Build Time Analysis
| Metric | Native Image | JVM | Difference |
|---|---|---|---|
| Build Time | ~210 seconds | ~15 seconds | 195 seconds (1300% slower) |
| Build Complexity | High (AOT compilation) | Low (Standard compilation) | - |
Key Observations:
- Native Image builds are significantly slower due to AOT compilation
- JVM builds are faster and more familiar to Java developers
- Native builds benefit from Docker layer caching
Docker Image Size Comparison
Native Image Dockerfile
FROM quay.io/quarkus/quarkus-micro-image:2.0
WORKDIR /work/
COPY target/*-runner /work/application/application
EXPOSE 8080
ENTRYPOINT ["/work/application/application"]
Results:
- Image Size: 123 MB
-
Base Image:
quarkus-micro-image:2.0(minimal UBI-based image) - Contents: Native executable only
JVM Dockerfile
FROM eclipse-temurin:21-jre-alpine
WORKDIR /work/
COPY target/quarkus-app /work/application
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/work/application/quarkus-run.jar"]
Results:
- Image Size: 254 MB
-
Base Image:
eclipse-temurin:21-jre-alpine(Alpine Linux with JRE) - Contents: JAR file + JRE
Image Size Analysis
| Metric | Native Image | JVM | Difference |
|---|---|---|---|
| Docker Image Size | 123 MB | 254 MB | 131 MB (107% larger for JVM) |
| Base Image | Minimal UBI | Alpine + JRE | - |
| Application Artifact | Native executable | JAR + dependencies | - |
Key Observations:
- Native images are typically smaller (no JRE included)
- JVM images include the full JRE runtime
- Alpine base images help reduce JVM image size
Startup Time Comparison
Native Image Startup
Native executables start almost instantly because:
- No JVM initialization
- No class loading
- No JIT compilation
- Pre-compiled machine code
Results:
- Startup Time: 0.079 s (79 ms) (from Quarkus application logs)
Actual Log Output:
quarkus-app | 2025-11-16 23:09:36,900 INFO [io.quarkus] (main) quarkus-native-users 1.0.0-SNAPSHOT native (powered by Quarkus 3.15.1) started in 0.079s. Listening on: http://0.0.0.0:8080
JVM Startup
JVM applications take longer to start because:
- JVM initialization
- Class loading
- JIT compilation (happens at runtime)
- Warmup period
Results:
- Startup Time: 2.292 s (from Quarkus application logs)
Actual Log Output:
quarkus-jvm-app | 2025-11-16 23:11:45,553 INFO [io.quarkus] (main) quarkus-native-users 1.0.0-SNAPSHOT on JVM (powered by Quarkus 3.15.1) started in 2.292s. Listening on: http://0.0.0.0:8080
Note: These startup times are measured by Quarkus itself and represent the actual application startup time (from application initialization to ready state), which is more accurate than external measurements that include container startup overhead.
Startup Time Analysis
| Metric | Native Image | JVM | Difference |
|---|---|---|---|
| Startup Time | 0.079 s (79 ms) | 2.292 s | 2.213 s (~29x faster for Native) |
| Cold Start | Instant | Requires warmup | - |
| First Request | Fast | Slower (JIT not warmed up) | - |
Key Observations:
- Native Image starts significantly faster (often 10-100x faster)
- JVM requires warmup time for optimal performance
- Native Image is ideal for serverless and short-lived containers
Memory Usage Comparison
Memory at Startup
Memory usage immediately after application startup:
Native Image:
- Memory Usage: 16.27 MiB (at startup, measured from Docker stats)
- Container Limit: 64 MiB (25.43% utilization)
- Components: Native executable, minimal runtime
JVM:
- Memory Usage: 113.5 MiB (at startup, measured from Docker stats)
- Container Limit: 128 MiB (88.69% utilization)
- Components: JVM heap, metaspace, code cache, JIT compiler
Real Docker Stats Output (JVM at Startup):
CONTAINER ID NAME MEM USAGE / LIMIT MEM %
40292f4fa0a6 quarkus-jvm-app 113.5MiB / 128MiB 88.69%
Real Docker Stats Output (Native at Startup):
CONTAINER ID NAME MEM USAGE / LIMIT MEM %
435d098f0299 quarkus-app 16.27MiB / 64MiB 25.43%
Memory Under Load
Memory usage after handling API requests:
Native Image:
- Memory Usage: 26.71 MiB (under load, after creating users)
- Container Limit: 64 MiB (41.74% utilization)
- Growth: ~10.44 MiB from startup (from 16.27 MiB baseline)
Real Docker Stats Output (Native Under Load):
CONTAINER ID NAME MEM USAGE / LIMIT MEM %
435d098f0299 quarkus-app 26.71MiB / 64MiB 41.74%
JVM:
- Memory Usage: 122.6 MiB (under load, after creating users)
- Container Limit: 128 MiB (95.76% utilization)
- Growth: ~9.1 MiB from startup (from 113.5 MiB baseline)
Real Docker Stats Output (JVM Under Load):
CONTAINER ID NAME MEM USAGE / LIMIT MEM %
40292f4fa0a6 quarkus-jvm-app 122.6MiB / 128MiB 95.76%
Memory Analysis
| Metric | Native Image | JVM | Difference |
|---|---|---|---|
| Memory at Startup | 16.27 MiB | 113.5 MiB | 97.23 MiB (~86% less for Native) |
| Memory under Load | 26.71 MiB | 122.6 MiB | 95.89 MiB (~78% less for Native) |
| Memory Growth | ~10.44 MiB | ~9.1 MiB | - |
Key Observations:
- Native Image uses 86% less memory at startup (16.27 MiB vs 113.5 MiB)
- Under load, Native uses 78% less memory (26.71 MiB vs 122.6 MiB)
- Native Image grows by ~10.44 MiB under load, while JVM grows by ~9.1 MiB
- JVM has significant overhead from JIT compiler, code cache, and runtime structures
- Native Image uses only 25.43% of its 64MB limit at startup and 41.74% under load
- JVM uses 88.69% of its 128MB limit at startup and 95.76% under load
- JVM reaches 95.76% of its 128MB limit under load, leaving minimal headroom
- Native Image maintains significant headroom (58.26% available) even under load
- Native Image is ideal for memory-constrained environments
Build Artifact Size
Native Executable
The compiled native executable:
- Size: ~98.65 MB (native executable)
-
Location:
target/*-runner - Type: Platform-specific binary
JVM JAR
The packaged JAR file:
- Size: ~48.67 MB (quarkus-app directory total)
-
Location:
target/quarkus-app/quarkus-run.jar - Type: Java bytecode archive
Artifact Size Analysis
| Metric | Native Executable | JVM JAR | Notes |
|---|---|---|---|
| Size | ~99 MB | ~49 MB | Native executable is larger but includes everything |
| Portability | Platform-specific | Cross-platform | JAR runs on any JVM |
Performance Summary
Quick Comparison Table
| Metric | Native Image | JVM | Winner |
|---|---|---|---|
| Build Time | ~210s (3.5 min) | ~15s | JVM (14x faster) |
| Docker Image Size | 123 MB | 254 MB | Native (52% smaller) |
| Startup Time | 0.079 s (79 ms) | 2.292 s | Native (~29x faster) |
| Memory (Startup) | 16.27 MiB | 113.5 MiB | Native (86% less) |
| Memory (Load) | 26.71 MiB | 122.6 MiB | Native (78% less) |
| Runtime Performance | Good | Excellent (after warmup) | JVM (long-term) |
When to Use Native Image
Choose Native Image when:
✅ Fast startup is critical
- Serverless functions (AWS Lambda, Azure Functions)
- Microservices with frequent scaling
- Short-lived containers
- Kubernetes with aggressive scaling policies
✅ Memory is constrained
- Edge computing devices
- Resource-limited environments
- High-density deployments
✅ Cold start performance matters
- Applications with infrequent traffic
- Batch processing jobs
- Scheduled tasks
✅ Smaller deployments
- Reduced image sizes
- Faster container pulls
- Lower storage costs
When to Use JVM
Choose JVM when:
✅ Build time is a concern
- Frequent deployments
- CI/CD pipeline speed matters
- Development iterations
✅ Maximum runtime performance needed
- Long-running applications
- High-throughput systems
- After JIT warmup, JVM can outperform native
✅ Easier debugging and profiling
- Standard Java tooling works
- JVM profilers (JProfiler, VisualVM)
- Better reflection and dynamic features
✅ Complex reflection/dynamic code
- Frameworks with heavy reflection
- Dynamic class loading
- Some libraries not fully native-compatible
Configuration Examples
Building Native Image
cd quarkus
./mvnw -Pnative -Dquarkus.native.container-build=true \
-Dquarkus.container-image.build=true \
-Dquarkus.container-image.name=quarkus-native-users \
-Dquarkus.container-image.tag=latest \
clean package
Building JVM Image
cd quarkus
./mvnw -Pjvm -Dquarkus.container-image.build=true \
-Dquarkus.container-image.name=quarkus-jvm-users \
-Dquarkus.container-image.tag=latest \
clean package
Docker Compose
Native:
docker-compose up -d
JVM:
docker-compose -f docker-compose.jvm.yml up -d
Real-World Use Cases
Serverless Functions
Native Image is ideal for serverless:
- Ultra-fast cold starts
- Minimal memory footprint
- Cost-effective (pay per invocation)
Microservices
Both can work, but consider:
- Native: If you need fast scaling and low memory
- JVM: If you have long-running services and need maximum throughput
Batch Processing
Native Image excels for:
- Short-lived batch jobs
- Scheduled tasks
- One-time processing
Traditional Web Applications
JVM is often better for:
- Long-running applications
- High-traffic websites
- Applications benefiting from JIT optimization
Conclusion
Both Quarkus Native Image and JVM have their place in modern Java development:
- Native Image wins on startup time, memory usage, and image size
- JVM wins on build time, long-term performance (after warmup), and developer experience
The choice depends on your specific requirements:
- Choose Native Image for serverless, microservices with frequent scaling, and memory-constrained environments
- Choose JVM for traditional applications, maximum runtime performance, and faster development cycles
Key Takeaways
- Native Image offers ~29x faster startup (0.079s vs 2.292s) and 86% less memory at startup (16.27 MiB vs 113.5 MiB)
- JVM offers 14x faster builds (~15s vs ~210s) and better long-term performance after warmup
- Native Image produces 52% smaller Docker images (123MB vs 254MB)
- Both are production-ready and well-supported by Quarkus
- Measure your specific use case to make the best decision
Resources
Follow me on GitHub and connect with ForTek Advisor for more technical content and project updates.
Originally published at https://fortek-advisor.com on November 17, 2025.
Top comments (0)