Skip to content
Alex GavriscoFounder & PrincipalSoftware EngineerAG Systems
Writing

Spring Boot: are we native yet?

Runtime14 min read

tl;dr: I gave Spring Boot native another chance to see how small I could run a real commerce backend. After a few fixes and performance tests, it now runs on 0.25 vCPU and 512 MiB. The JVM's AOT cache also surprised me, and I'm keeping both runtimes available as the project grows.

What I learned running Spring Boot native in production.

I’ve been following GraalVM since its early releases. The idea appealed to me: write services and tools in Java or Kotlin, compile them to a native executable, get faster startup, and hopefully a smaller footprint. My attempts to adopt it between 2020 and 2024 yielded mixed results. Making it work still required significant effort.

While looking for ways to make a project cheaper to run, I decided to give Spring Boot with GraalVM Native Image another chance.

Good developer experience and “batteries included” help you deliver. Boring technologies help you sleep better when you’re on call. Spring Boot has given me a good balance of maturity, application capabilities, and support for new Java and Kotlin features. If Native Image could lower its baseline resource requirements, I could keep that stack while running a smaller Fargate task.

This time, Spring Boot + Native Image surprised me: a service that uses a lot of Spring Boot features runs in production on just 0.25 vCPU and 512 MiB. Getting there took some configuration, but way less effort and time than my earlier attempts.

What I was building

The service is an e-commerce backend built as a modular monolith. It has about 60 API endpoints with generated API contracts and more than 30 DB migrations. It handles authentication and RBAC-based authorization, transactions and failure recovery, image processing, and external integrations. Rich error types help provide useful validation feedback. Metrics, structured logs, and telemetry support observability.

The versions behind the measurements are Kotlin 2.4, Spring Boot 4.1, JDK 25, and Jackson 3. The stack uses Spring MVC with Jetty and virtual threads, Spring Data JDBC with PostgreSQL, and Jackson’s Kotlin module. Spring Boot starters and supporting libraries cover security, OAuth2, transactions, caching, mail, templating, and logging.

At first, I deployed the Native Image to the Fargate task with 0.5 vCPU and 1 GiB. After performance testing, I reduced the allocation to 0.25 vCPU and 512 MiB (the smallest instance!) before release. The runtime switch and the downsizing were separate decisions.

The goal was to lower baseline compute cost while meeting the required request rate and latency. Saving during quiet periods also leaves more budget for additional tasks when traffic picks up (autoscaling takes care of that). The task can grow again without rewriting the service.

Getting Spring Boot to run native

Adding native support to a Spring Boot project requires waiting for the planets to align starts with one Gradle plugin:

plugins {
  id("org.graalvm.buildtools.native")
}

Spring Boot detects the plugin and configures its AOT tasks. Native Build Tools ↗ adds nativeCompile, nativeRun, and nativeTest. The regular JVM workflow remains available.

With the plugin applied, bootBuildImage also produces a container running the native executable. This is the production build path for the project’s server module:

./gradlew :server:bootBuildImage

The buildpack supplies the compiler, so this path needs Docker. Local nativeCompile needs a GraalVM Native Image toolchain. The measured production image was compiled by Paketo’s Liberica NIK 25 builder, with Serial GC and no profile-guided optimization. Those choices matter when comparing results.

I also wanted Spring AOT enabled during local development. It discovers the application’s bean graph and generates startup code at build time. That output can run on the JVM too. An explicit bootRun configuration looks like this; the examples here are shortened to show the relevant wiring:

tasks.named<BootRun>("bootRun") {
  if (!project.hasProperty("no-aot")) {
    dependsOn("aotClasses")
    classpath +=
      sourceSets["aot"].output
    jvmArgs(
      "-Dspring.aot.enabled=true",
    )
  }
}

The dependency and classpath addition make the generated assets available. The flag selects AOT startup; -Pno-aot skips this setup for an ordinary JVM run. Running AOT on the JVM still doesn’t enforce Native Image’s reflection restrictions.

Two adjustments made this useful early. Spring Data JDBC needed an explicit PostgreSQL dialect bean for AOT processing. I also had to keep the developer’s local profile out of processAot:

tasks.named<JavaExec>("processAot") {
    systemProperty(
        "spring.profiles.active", "",
    )
}

Spring AOT fixes bean-selection decisions at build time ↗, including profile-based ones. That is different from changing an ordinary runtime property. Checking the AOT environment locally exposes these assumptions before a native build makes them harder to investigate.

The parts that needed attention

Spring and the libraries handled much of the native configuration. The remaining work involved image processing, JSON binding, and resources reached through paths the build could not discover automatically.

Image processing

The image-transformation code uses javax.imageio.ImageIO, which reaches into AWT. It failed in the early native build. Setting java.awt.headless=true during native compilation fixed this path. For the buildpack build:

tasks.named<BootBuildImage>(
  "bootBuildImage",
) {
  environment.put(
    "BP_NATIVE_IMAGE_BUILD_ARGUMENTS",
    "-Djava.awt.headless=true",
  )
}

The equivalent local native-build setting belongs in the binary’s systemProperties map. The resulting build included libawt_headless.so, libjavajpeg.so, and liblcms.so beside the executable; the buildpack packaged them into the container. A successful compile alone would not have told me whether an image upload and resize actually worked in that container.

JSON binding and resources

Polymorphic JSON was an obvious place to need reflection hints. A subtler case was Kotlin data classes with default parameters, used for payloads stored as JSONB. Reading a payload that omits an optional field can invoke the synthetic constructor for default arguments. The traced tests supplied every field, so they did not exercise that constructor. That left a deserialization path uncovered in native.

I registered the JSONB-bound types explicitly. A shortened selection is:

@Configuration
@RegisterReflectionForBinding(
    ProductTraits::class,
    EmailOutboxPayload::class,
    OrderPaymentInfo::class,
    TaxSnapshot::class,
)
class ModelTypeHintsConfiguration

These hints cover binding access that Spring cannot infer from the application’s bean graph, such as an object mapper called from domain code. For families of types, a RuntimeHintsRegistrar can discover and register them during AOT processing. For the sealed error DTOs, the hierarchy itself supplies the types. Inside the registrar:

val binding =
  BindingReflectionHintsRegistrar()
val root = ApiErrorDetails::class
val types =
  root.sealedSubclasses + root
types.forEach {
  binding.registerReflectionHints(
    hints.reflection(), it.java,
  )
}

This registers the root and its direct subclasses. A hierarchy with further sealed levels needs those levels covered too. Generated DTO packages can also be scanned at build time and their types passed to the same binding registrar.

Resources need attention too. Inside a registrar, the email assets were registered along these lines:

hints.resources()
    .registerPattern("email/*.png")
hints.resources()
    .registerPattern(
        "templates/email/*.html",
    )

The registrar is connected through @ImportRuntimeHints. Spring’s runtime hints guide ↗ covers both binding and resource registration. Other adjustments covered Hibernate Validator, Jakarta Mail, Jetty request logging, and Kotlin’s empty collection implementations. The handwritten hints totaled about 150 lines.

The tracing agent’s blind spots

The tracing agent recorded roughly 32,000 lines of metadata, including about 3,000 reflection entries and 1,400 resources. About a quarter of the reflection entries were application DTOs. Testcontainers, Mockito, and other test infrastructure also appeared because the agent ran during the test task.

Even that much metadata did not cover the omitted-field constructor path. The agent records dynamic access that actually happens during a run.

I kept collection opt-in. The project’s metadataCopy configuration copies the test output into src/main/resources/META-INF/native-image and merges it with the committed files:

./gradlew -Pagent test
./gradlew metadataCopy

The generated files need review, and the native artifact still needs testing. The explicit hints document the types and resources the service depends on; the agent helps discover gaps.

Testing the image that ships

I initially relied on nativeTest. Roughly 360 tests ran in under five seconds once the test binary existed, but compilation took around two minutes and nearly 12 GB of memory. That was expensive both locally and in CI.

Passing those tests still left gaps: they execute in a separate native test executable ↗, with its own reachable code and configuration, rather than in the production image.

The replacement E2E suite started with 51 black-box tests against the application container and a throwaway PostgreSQL database. Coverage was organized by mechanism: JSON binding, custom validation and error types, authentication, image uploads, and background jobs. That was more useful than counting how many URLs had a test.

Client and server types come from the API schemas. Those schemas and the specs also guided AI-assisted test writing. Together they helped me build extensive coverage without much maintenance overhead. The suite can target either a JVM or native service through its base URL.

The pipeline now builds the application image, runs the E2E suite against it, and pushes that image only after the tests pass. The suite has been cheap to run and effective at catching native issues. It is the part I would establish earlier on another project.

There was configuration to write, but the compatibility work stayed manageable. I could still use the Spring capabilities the service needed.

The footprint and the tradeoffs

Memory use and startup time were the metrics I most wanted to improve. The combined optimizations eventually let the service fit the smallest Fargate task ↗. To understand the runtime differences, I compared four variants locally in ARM64 containers limited to 0.25 vCPU and 512 MiB.

The cache variant adds the JDK AOT cache ↗ on top of Spring AOT. Spring AOT generates application startup code; the JDK cache reuses JVM work such as class loading and linking. It is still a JVM deployment.

Variant First healthy response Idle memory Peak memory during load Sustained 25 RPS?
JVM 177 s 353 MiB 480 MiB No
JVM + Spring AOT 151 s 302 MiB 480 MiB No
JVM + Spring AOT + JDK AOT cache 80 s 202 MiB 485 MiB No
Native 5.8 s 204 MiB 307 MiB Yes, in the selected run
measurement note:

Idle memory was sampled 60 seconds after startup; loaded memory is the sampled peak during a 25 RPS step. The JVM rows were overloaded, so these are not memory measurements at equivalent successful throughput. Other native runs also suffered large host-dependent slowdowns. Production capacity was checked separately.

Local startup at 0.25 vCPU and 512 MiB Representative recorded ARM64 laptop-container runs, seconds to first successful health response: JVM 177; JVM with Spring AOT 151; JVM with Spring AOT and JDK AOT cache 80; native image 5.8. These are not Fargate startup measurements. JVM 177 s JVM + Spring AOT 151 s JVM + Spring AOT + cache 80 s Native image 5.8 s 0 50 100 150 200 s
fig. 1:

time to first healthy response at 0.25 vCPU and 512 MiB

The JVM with the AOT cache surprised me. Compared with Spring AOT alone, it roughly halved startup time and brought idle memory almost level with native. If that improvement meets a project’s requirements, I would stop there and reassess whether native is necessary.

Local container memory in MiB at 0.25 vCPU and 512 MiB Representative ARM64 laptop-container runs. Open circles show idle memory 60 seconds after startup, filled circles show the sampled peak during a 25 RPS step. JVM: 353 to 480 MiB; JVM with Spring AOT: 302 to 480; JVM with Spring AOT and JDK AOT cache: 202 to 485; native: 204 to 307. The JVM variants were overloaded, so the peaks are not at equal successful throughput. The container limit is 512 MiB. JVM 353 → 480 JVM + Spring AOT 302 → 480 JVM + Spring AOT + cache 202 → 485 Native image 204 → 307 0 100 200 300 400 512 idle at 60 s peak during load
fig. 2:

container memory in MiB, idle to peak during load; JVM variants were overloaded

The loaded measurements still left more room under the memory limit for native. Giving the JVM 1 GiB on the same quarter core did not rescue the local load test: 98% of requests timed out, with container memory around 484 MiB and CPU at its quota. No container was killed for memory. I suspected JIT warmup was involved, but did not collect compilation logs to establish that mechanism.

For container sizing, I used k6 runs against a single native task on Fargate. At about 100 RPS, the product-listing endpoint stayed within the test’s target of roughly 500 ms at p95 and under 1% errors on 0.25 vCPU and 512 MiB. This is the production evidence behind the smaller allocation.

Halving CPU and memory also halved task compute. The capacity estimate went from about $18 to $9 per month for one x86 task in us-east-1 at 730 hours. That is task compute only; the database, load balancer and other infrastructure components are separate costs.

When there is more CPU

A separate local comparison at 1 vCPU and 1 GiB showed why I would keep the JVM option. At an offered 400 RPS, both variants completed roughly 400 requests per second:

Variant p50 at 400 RPS p99 at 400 RPS
JVM + Spring AOT 2.4 ms 12.6 ms
Native 13.2 ms 204 ms
measurement note:

One run per variant, with a 10-second ramp and 30-second hold. These are local Docker limits; 1 vCPU with 1 GiB is not a Fargate task configuration. Each run stepped through 100, 200, 400, 800, and 1,600 offered RPS. Both variants met the latency/error target at 400 RPS and missed it at 800; these steps do not locate the healthy ceiling between them.

The rest of the run shows how they behaved beyond that point. At 800 offered RPS the JVM completed about 771 requests per second, while native completed about 434. At 1,600, those figures were 929 and 403. These totals include failed attempts; they describe overload behavior, not traffic the service could handle within its latency target.

Local completed request rate at 1 vCPU and 1 GiB One run per variant. At offered rates 100, 200, 400, 800 and 1600 RPS, JVM with Spring AOT completed 100, 200, 400, 771 and 929 attempts per second; native completed 100, 200, 399, 434 and 403. Counts include failures and client timeouts. At 800 RPS and above k6 also dropped offered work after reaching its virtual-user cap. These results do not establish healthy capacity. JVM + Spring AOT Native image Offered RPS Completed/s 100 100 100 200 200 200 400 400 399 800 771 434 1,600 929 403 0 250 500 750 1,000
fig. 3:

completed requests per second across the one-core run, including failures

Local p99 latency at 1 vCPU and 1 GiB One run per variant. At offered rates 100, 200, 400, 800 and 1600 RPS, JVM with Spring AOT p99 latency was 10, 7.9, 12.6, approximately 1800 and approximately 1600 milliseconds. Native p99 was 8.7, 16.5 and 204 milliseconds through 400 RPS. At 800 and 1600 native had 15-second client timeouts, so native p99 is unknown and is not plotted for those steps. The latency axis is logarithmic; offered rates double at each horizontal step. JVM + Spring AOT Native image p99 in ms · logarithmic scale 5 10 100 1,000 2,000 10 7.9 12.6 1,800 1,600 8.7 16.5 204 100 200 400 800 1,600 Offered RPS · doubling steps Native: timeouts at 800 and 1,600 RPS. p99 unknown; no native points plotted.
fig. 4:

p99 latency across the same run, on a log scale; native p99 is unknown at the timeout steps

measurement note:

At 800 RPS and above, k6 reached its 400-VU cap and dropped some offered work. Native also recorded 1% and 2% errors at the final two steps, with 15-second client timeouts. Those steps have no trustworthy native p99 value to plot. These plots use the same two runs as the small table. The JDK-cache variant was measured in a separate session.

Native’s smaller footprint did not give it the JVM’s request latency at this CPU budget. I would measure again before changing runtimes as traffic grows.

The small production task also exposed work no runtime switch could remove. Decoding and resizing full-resolution product images on demand saturated its CPU. Precomputed renditions, served as blob reads, removed that repeated work.

Where the JVM can win

The Quarkus team’s March 2026 benchmark write-up ↗ reports the same broad tradeoff: native builds cut throughput to roughly half the JVM result for Quarkus, with a similar penalty for Spring Boot. Their benchmark uses Hibernate and a different workload from this Spring Data JDBC service. I take that as useful evidence that the tradeoff deserves measurement, rather than a ratio to apply to my deployment.

My native numbers also reflect one build configuration: Liberica NIK, Serial GC, and no PGO. Oracle GraalVM offers G1 and profile-guided optimization ↗, and Paketo can select that distribution ↗. I have not tested either option here, so I cannot say how much they would change the result. With more CPU available, the JVM is already a credible option without adding a native tuning step.

Building and operating it

The main drawback for me is CI overhead. One recorded local bootBuildImage run took 4 minutes 49 seconds including image pulls. Native compilation within it took 74 seconds and peaked at 10.5 GB RSS. Those are local build figures, but they show the compilation resources a CI runner must accommodate.

I would also align toolchains more deliberately. Development, CI, native tests, and the buildpack used different JDK 25 distributions. The buildpack determined the compiler and collector in the shipped binary. Writing those choices down makes a performance comparison easier to interpret and reproduce.

Diagnostics deserve an explicit build decision too. GraalVM supports options such as --enable-monitoring=heapdump,jfr ↗; choose the diagnostics you need before you need them in production.

Where I landed

Spring Boot native is a production option I would use again, or at least keep it in mind. Framework and library support covered much of the work, the remaining adjustments were manageable, and the service kept the capabilities I wanted while running on a smaller task. Native build cost was the most significant downside.

I would still try the JVM’s AOT cache before committing to native on a similar project. If it meets the memory and startup requirements, that may be enough. If native creates useful room in the resource budget, test the actual image and account for compilation in the delivery pipeline.

The runtime is a choice I can revisit. The same service and E2E suite support JVM and native deployments; I can increase the task size, add tasks, or change runtime as the business grows. Keeping Spring Boot’s development experience while making that small deployment practical was the result I wanted.

Measurement notes

Local runs were recorded on 12 October 2026 on an Apple M4 Pro, using OrbStack, ARM64 Linux containers, PostgreSQL 17.5 in a sibling container, and external integrations disabled. Production runs x86_64 on AWS Fargate.

Startup is wall time from docker run to the first successful GET /app/health, polled every 50 ms. Memory is container usage reported by docker stats ↗, sampled every two seconds during load, rather than process RSS. The quarter-CPU table uses representative runs from three per JVM variant and four native runs; the load step offered 25 RPS to a 14 KB product-listing response, with a 10-second ramp and 40-second hold.

Every JVM variant used MaxRAMPercentage=60. Native was Paketo/Liberica NIK 25 with Serial GC, without PGO. The JDK cache training run exited at application context refresh, so it did not train request paths. The one-core results are single runs and should be read as observations, not stable benchmark ratios.

Contact

Two ways to work together

For a product or platform that needs to be built, modernized, or operated, start with AG Systems. For a senior or lead engineering role, write to me directly.