Throughout my work on financial systems, most of the services I’ve built were based on Spring Boot. It’s the default choice in many enterprise environments, mature, well-documented, and widely adopted in production.
However, many of these services run in environments with traffic spikes and aggressive cost optimization strategies. In some cases, we deployed on AWS Spot instances, which can be terminated at any time when the spot price exceeds the configured bid.
That means:
- Fast startup matters
- Resource efficiency matters
- Predictable performance under constrained CPU and memory matters
To optimize costs while maintaining performance, I started exploring alternatives within the Java ecosystem. Although there are several high-performance backend languages such as Go, Rust, and Node.js, I wanted to stay within Java and evaluate another approach I had been reading about frequently:
Quarkus compiled to a native image using GraalVM.
The goal was simple:
Can Quarkus Native be a viable alternative to Spring Boot in production scenarios where startup time, memory usage, and container efficiency are critical?
⚠️ Important Disclaimer
This is not a pure framework speed comparison.
This article compares:
- Spring Boot running on the JVM
- Quarkus compiled to a GraalVM native image
This is intentionally not apples-to-apples.
It is a comparison between two different runtime strategies:
- A traditional JVM-based application
- A native-compiled Java application
The purpose is not to declare a winner, but to explore how runtime choices impact performance, resource usage, and scalability in real-world conditions.
Test Architecture
Workload
- HTTP POST request
- Each request:
- Publishes message to Kafka
- Consumer reads message
- Persists to PostgreSQL
Host Machine
- Apple Mac Mini M4 (ARM64)
- 24 GB RAM
- macOS
- Docker
Container Limits
- 1 CPU per service
- Spring Boot: 256 MB (128 MB also ran initially, but under sustained load the container was eventually killed due to out-of-memory).
- Quarkus Native: 128 MB
- Kafka + PostgreSQL shared
Metrics stack:
- Prometheus
- Grafana
- Micrometer (Spring + Quarkus)
Startup Time Comparison
Before running any stress tests, I measured cold startup time inside the container.
Quarkus Native (3.31.1) Started in 0.118 seconds
Spring Boot 3.5.7 (JVM) Root WebApplicationContext initialized in 1492 ms (~1.49 seconds)
This represents roughly a 12× faster startup time for Quarkus Native.
Why this matters:
- Faster container scaling
- Faster recovery from crashes
- Better behavior in Spot / preemptible environments
- Reduced cold start impact in serverless-style deployments
Startup time does not directly impact steady-state throughput, but it significantly impacts operational elasticity and cost optimization strategies.
First stress test
wrk -t4 -c10 -d120s -s post_c10.lua http://127.0.0.1:8082/event &
wrk -t4 -c10 -d120s -s post_c10.lua http://127.0.0.1:8083/event &
wait
| quarkus-native | spring-app | |
|---|---|---|
| host | http://127.0.0.1:8082 | http://127.0.0.1:8083 |
| connections | 10 | 10 |
| req/sec | 1168.78 | 3939.24 |
| avg latency | 6.86ms | 12.44ms |
| cpu max | 71.4% | 100% |
| cpu units max | 0.714 | 1000 |
| Memory usage container max | 40mb | 256mb |
| Heap JVM memory used bytes max | 29mb | 102mb |
| Kafka consumed | 45 seconds faster than spring | |
| total messages inserted | 613195 | 613195 |
| total messages by framework created | 280682 | 945708 |
For Quarkus Native, jvm metrics represent native process memory compatibility metrics rather than traditional JVM heap pools._
Results Under 10 Concurrent Connections
HTTP Throughput & Latency
Under 10 concurrent connections:
• Spring Boot processed ~3.4× more requests per second.
• Spring achieved significantly higher HTTP throughput.
• Spring saturated the CPU (100%), while Quarkus did not.
This indicates that at low concurrency:
Spring utilized the CPU more aggressively and converted it into higher HTTP throughput.
At the same time:
• Quarkus showed lower average latency per request.
• Even while processing fewer requests per second.
This suggests:
• Quarkus handles individual requests efficiently.
• Spring increases throughput at the cost of higher average latency.
• Spring likely increases internal concurrency to maximize CPU utilization.
This reflects a classical trade-off:
• Spring → Higher throughput
• Quarkus → Lower per-request latency
⸻
CPU Utilization
• Spring fully saturated the allocated CPU (100%).
• Quarkus peaked around ~70%.
This means:
• Spring extracted maximum processing capacity from the allocated core.
• Quarkus left ~30% CPU headroom in this scenario.
If raw throughput is the goal:
Spring utilized the available compute resources more aggressively.
⸻
Memory Usage
The memory difference was significant:
• Quarkus Native: ~40 MB
• Spring Boot: ~256 MB
That is roughly 6× higher memory usage for Spring.
From a container density perspective:
Quarkus Native is substantially more memory-efficient.
This difference becomes particularly relevant in high-density container deployments or memory-constrained environments.
⸻
Kafka Consumption Behavior
In equal-volume scenarios:
• Both frameworks inserted the same total number of messages.
• Quarkus drained the Kafka backlog ~45 seconds faster.
⸻
Summary Under 10 Connections
Spring Boot
• Higher HTTP throughput
• Fully utilized CPU
• Higher memory usage
• Higher average latency
Quarkus Native
• Lower HTTP throughput
• Lower CPU usage
• Much smaller memory footprint
• Lower latency
• Faster Kafka drain
Second stress test
wrk -t4 -c50 -d120s -s post_c50.lua http://127.0.0.1:8082/event &
wrk -t4 -c50 -d120s -s post_c50.lua http://127.0.0.1:8083/event &
wait
| quarkus-native | spring-app | |
|---|---|---|
| host | http://127.0.0.1:8082 | http://127.0.0.1:8083 |
| connections | 50 | 50 |
| req/sec | 6038.77 | 7123.39 |
| avg latency | 8.31ms | 17.13ms |
| cpu max | 96.7% | 100% |
| cpu units max | 0.967 | 1000 |
| Memory usage container max | 58.8mb | 256mb |
| Heap JVM memory used bytes max | 38.4mb | 101mb |
| Kafka consumed | consumed same time | consumed same time |
| total messages inserted | 1580540 | 1580540 |
| total messages by framework created | 1448099 | 1706765 |
For Quarkus Native, jvm metrics represent native process memory compatibility metrics rather than traditional JVM heap pools._
Results Under 50 Concurrent Connections
HTTP Throughput & Latency
Under 50 concurrent connections:
• Spring Boot processed more requests per second (7123 vs 6038).
• The throughput gap narrowed compared to the 10-connection test.
• Both frameworks scaled significantly relative to low concurrency.
However:
• Quarkus maintained significantly lower latency.
• Quarkus: ~8.31 ms
• Spring: ~17.13 ms
This suggests:
• Quarkus continues to handle individual requests more efficiently.
• Spring increases throughput by pushing concurrency harder.
• Latency under load grows more aggressively in Spring.
This reinforces the same trade-off observed earlier:
• Spring → Higher throughput
• Quarkus → Lower latency stability
⸻
CPU Utilization
• Spring saturated CPU at 100%.
• Quarkus reached ~96.7%.
At this concurrency level:
• Both frameworks effectively utilized nearly the full CPU capacity.
• The previous CPU utilization gap (seen at 10 connections) largely disappeared.
This indicates:
Under higher concurrency, Quarkus is capable of fully utilizing allocated CPU resources.
The throughput difference here is no longer explained by unused CPU, but more likely by:
• Internal threading model differences
• HTTP stack implementation
• Serialization overhead
• Framework-level concurrency strategies
⸻
Memory Usage
Memory efficiency remained highly differentiated:
• Quarkus Native: ~58.8 MB
• Spring Boot: ~256 MB
Spring maintained roughly 4–5× higher container memory usage.
Heap usage:
• Quarkus (native compatibility metrics): ~38.4 MB
• Spring JVM heap: ~101 MB
From a container density perspective:
Quarkus Native still provides a substantial memory advantage under moderate concurrency.
⸻
Kafka Consumption Behavior
At 50 connections:
• Both frameworks consumed Kafka messages at approximately the same time.
• Total inserted messages were identical:
• 1,580,540 records each
However:
• Spring produced more total messages during the test:
• Spring: ~1,706,765
• Quarkus: ~1,448,099
⸻
Summary Under 50 Connections
Spring Boot
• Higher HTTP throughput
• Fully saturated CPU
• Higher memory footprint
• Higher average latency
Quarkus Native
• Slightly lower HTTP throughput
• Nearly full CPU utilization
• Significantly lower memory usage
• Much lower average latency
• Equivalent Kafka consumption time
Third stress test
wrk -t4 -c200 -d120s -s post_c50.lua http://127.0.0.1:8082/event &
wrk -t4 -c200 -d120s -s post_c50.lua http://127.0.0.1:8083/event &
wait
| quarkus-native | spring-app | |
|---|---|---|
| host | http://127.0.0.1:8082 | http://127.0.0.1:8083 |
| connections | 200 | 200 |
| req/sec | 9942.45 | 7340.39 |
| avg latency | 24.47ms | 33.18ms |
| cpu max | 100% | 100% |
| cpu units max | 1000 | 1000 |
| Memory usage container max | 71.5mb | 256mb |
| Heap JVM memory used bytes max | 45.9mb | 101mb |
| Kafka consumed | 90 seconds faster than quarkus | |
| total messages inserted | 2075484 | 2075484 |
| total messages by framework created | 2387534 | 1763434 |
For Quarkus Native, jvm metrics represent native process memory compatibility metrics rather than traditional JVM heap pools._
Results Under 200 Concurrent Connections
HTTP Throughput & Latency
Under 200 concurrent connections:
• Quarkus Native significantly outperformed Spring in throughput
• Quarkus: ~9,942 requests/sec
• Spring: ~7,340 requests/sec
• This represents a ~35% higher HTTP throughput for Quarkus.
• Both frameworks reached full CPU saturation.
At the same time:
• Quarkus maintained lower average latency:
• Quarkus: ~24.47 ms
• Spring: ~33.18 ms
This is an important shift from lower concurrency tests:
At high concurrency, Quarkus not only preserves latency efficiency, it also overtakes Spring in raw throughput.
This suggests:
• Quarkus scales more efficiently under heavy parallel load.
• Spring’s throughput advantage at low concurrency does not persist at higher concurrency.
• Native execution overhead remains stable even under CPU saturation.
⸻
CPU Utilization
• Both frameworks saturated CPU at 100%.
• Both were limited by the same 1-core container constraint.
This means:
• Throughput differences are not caused by CPU underutilization.
• The bottleneck shifted to internal framework efficiency under load.
At this point:
Quarkus converted CPU cycles into more HTTP throughput than Spring.
⸻
Memory Usage
Memory efficiency remained dramatically different:
• Quarkus Native: ~71.5 MB
• Spring Boot: ~256 MB
Even under heavy load:
• Quarkus consumed less than one-third of Spring’s container memory.
• Spring memory remained flat at the container limit.
Heap usage:
• Quarkus compatibility metric: ~45.9 MB
• Spring JVM heap: ~101 MB
From a container density perspective:
Quarkus Native remains substantially more memory-efficient even at maximum concurrency.
⸻
Kafka Consumption Behavior
Under 200 connections:
• Both frameworks inserted the same total number of records:
• 2,075,484 messages each
However:
• Quarkus produced significantly more total messages:
• Quarkus: ~2,387,534
• Spring: ~1,763,434
Despite producing fewer messages:
• Spring drained Kafka 90 seconds faster.
⸻
Summary Under 200 Connections
Quarkus Native
• Highest HTTP throughput
• Lower average latency
• Full CPU utilization
• Significantly lower memory footprint
• Produced more total messages
• Slower Kafka drain in this specific workload
Spring Boot
• Lower HTTP throughput at high concurrency
• Higher latency
• Full CPU utilization
• Much higher memory usage
• Faster Kafka drain
Fourth and Last Stress Test
Unfortunately, this test could not be completed.
During execution, the spring-app container was terminated due to an out-of-memory (OOM) condition.
To confirm the cause, I inspected the container state:
What this means
- Exit code 137 indicates the process was killed by the system (SIGKILL).
- OOMKilled=true confirms the container exceeded its memory limit.
- The Docker memory limit for Spring was set to 256 MB, which was insufficient under this load.
This highlights an important aspect of the comparison:
Under extreme concurrency, Spring Boot (running on the JVM) required more memory than the configured container limit allowed, leading to forced termination.
Meanwhile, Quarkus Native continued operating within its allocated memory constraints.
Final Thoughts and Conclusion
This benchmark was not about declaring a universal winner.
It was about understanding how runtime strategy impacts real-world behavior under constrained CPU and memory.
The most interesting finding wasn’t “which is faster,” but how differently they scale under pressure.
Choosing between JVM and native execution can impact performance characteristics more than choosing between frameworks.
And that decision should be guided by workload profile, infrastructure constraints, and operational goals, not by hype.











Top comments (4)
Hello. I'm not sure comparing graalvm native in one technology to jvm of another technology is a fair comparison. You would see reduced throughput numbers if you were comparing spring native to spring jvm.
If I were trying to figure out "jvm vs native" it might be better to stick to the same technology stack & versions of things - that way you are only changing 1 variable (i.e. spring jvm vs spring native, quarkus jvm vs quarkus native).
The better comparison would be to compare jvm of one technology to another. The Quarkus team has recently open sourced all of its benchmarks and comparisons: github.com/quarkusio/benchmarks
@edeandrea As I wrote in the article:
Important Disclaimer
This is not a pure framework speed comparison.
This is intentionally not apples-to-apples.
Understood, but your statement "To optimize costs while maintaining performance, I started exploring alternatives within the Java ecosystem." implies that Quarkus on the JVM is not a viable solution?
While I'm happy you are exploring Quarkus (yay!) I just want to be clear that native compilation in general (regardless of framework) has a small set of use cases. Generally-speaking, Java applications belong on the JVM. Native compilation (again, regardless of framework choice) is going to sacrifice overall throughput in order to get the small footprint.
Thanks for raising that. That wasn’t my implication at all.
When I wrote “exploring alternatives within the Java ecosystem”, I meant exploring different runtime strategies, not excluding the JVM as a viable solution.
In this experiment, I intentionally chose Quarkus Native because I wanted to evaluate scenarios where the following factors matter:
The goal wasn’t to suggest that the JVM is not viable, far from it. The JVM remains an excellent default for many workloads.
What I wanted to understand was how a native-compiled runtime behaves under constrained environments compared to a traditional JVM deployment.
I was actually impressed by how straightforward the Quarkus native setup was and how it performed under load.
Ultimately, my intention was to encourage experimentation. Sometimes we default to the same stack out of habit and I believe it’s healthy for us as engineers to occasionally challenge those defaults and evaluate alternatives with real measurements.
Appreciate the discussion. And I will test Quarkus on the JVM soon.