DEV Community

Bruno Minato
Bruno Minato

Posted on

From JVM to Native: My Performance Experiments with Spring Boot and Quarkus

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

  1. HTTP POST request
  2. Each request:
  3. Publishes message to Kafka
  4. Consumer reads message
  5. 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)

Collapse
 
edeandrea profile image
Eric Deandrea • Edited

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

Collapse
 
bruno_minato profile image
Bruno Minato

@edeandrea As I wrote in the article:

Important Disclaimer
This is not a pure framework speed comparison.
This is intentionally not apples-to-apples.

Collapse
 
edeandrea profile image
Eric Deandrea

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.

Thread Thread
 
bruno_minato profile image
Bruno Minato

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:

  • Startup time
  • Memory footprint
  • Container density
  • Resource-constrained environments
  • Native behavior with Kafka (producer & consumer)
  • Native behavior with PostgreSQL and connection pooling
  • How native export metrics

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.