How We Cut Spring Boot Startup from 12 s to 3 s with GraalVM Native Image

This article walks through converting a Spring Boot order‑query microservice to a GraalVM Native Image, detailing environment setup, common build pitfalls with concrete code fixes, Docker multi‑stage packaging, K8s scaling comparison, performance benchmarks, CI/CD integration, and guidance on when Native Image is appropriate.

Java Web Project
Java Web Project
Java Web Project
How We Cut Spring Boot Startup from 12 s to 3 s with GraalVM Native Image

Background: a 12‑second startup microservice

We had an order‑query service built with Spring Boot 3.2.4, running on Kubernetes. The service receives a request, hits Redis, queries MySQL and returns data. Its startup time of ~12 s caused readinessProbe delays, request queuing, and HPA scaling latency.

Part 1: What GraalVM Native Image changes

Traditional JVM vs Native Image

In JVM mode the startup involves class loading, bytecode verification, interpretation, JIT compilation, and Spring bean scanning, taking 8‑15 s. Native Image performs AOT compilation, eliminating class‑loader overhead, JIT warm‑up, and generates a native executable that starts in 50‑200 ms.

JVM requires five serial phases before ready; Native Image moves all analysis to build time.

Spring Boot 3 AOT processing

At build time Spring analyzes annotations, reflection and dynamic proxies, generates hints (reflect‑config.json, proxy‑config.json, resource‑config.json) and feeds them to the GraalVM compiler, which produces a single native binary.

Part 2: Environment setup

Installing GraalVM

# Recommended via SDKMAN
curl -s "https://get.sdkman.io" | bash
source "$HOME/.sdkman/bin/sdkman-init.sh"

sdk install java 21.0.2-graal
sdk use java 21.0.2-graal

# Verify
java -version
native-image --version
⚠️ Windows users must compile inside “x64 Native Tools Command Prompt” or use WSL2.

Part 3: Common pitfalls and fixes

Pitfall 1 – MySQL driver reflection failure

Error:

Caused by: java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver
    at com.zaxxer.hikari.util.DriverDataSource.<init>(DriverDataSource.java:110)

Solution: add a reflect‑config.json entry:

[
  {
    "name": "com.mysql.cj.jdbc.Driver",
    "allDeclaredConstructors": true,
    "allPublicMethods": true,
    "allDeclaredFields": true
  }
]

Pitfall 2 – Jackson serialization failure

Error:

InvalidDefinitionException: No serializer found for class OrderDTO

Solution: register the DTOs for reflection:

@RegisterReflectionForBinding({OrderDTO.class, OrderListDTO.class})
@RestController
@RequestMapping("/api/orders")
public class OrderController { ... }

Pitfall 3 – Lombok @Builder vs JPA @Entity conflict

Error:

Error: Field com.example.order.entity.Order.id is not accessible

Add the field to reflect‑config.json with allowUnsafeAccess:

{
  "name": "com.example.order.entity.Order",
  "allDeclaredFields": true,
  "fields": [
    {"name":"id","allowUnsafeAccess":true},
    {"name":"orderNo","allowUnsafeAccess":true}
  ]
}

Pitfall 4 – Build OOM (exit status 137)

Increase build memory:

<plugin>
  <groupId>org.graalvm.buildtools</groupId>
  <artifactId>native-maven-plugin</artifactId>
  <configuration>
    <buildArgs>
      <buildArg>-J-Xmx8g</buildArg>
      <buildArg>-H:+GenerateBuildReport</buildArg>
    </buildArgs>
  </configuration>
</plugin>
At least 8 GB RAM is required; CI machines should provide 16 GB.

Pitfall 5 – Lettuce connection pool init failure

Error:

NoSuchMethodException: io.lettuce.core.resource.DefaultClientResources.<init>

Fix: upgrade Lettuce to 6.3.2+ in pom.xml.

Pitfall 6 – Missing resource files at runtime

Add required patterns to resource‑config.json:

{
  "resources": {
    "includes": [
      {"pattern":"db/migration/.*\\.sql"},
      {"pattern":"i18n/.*\\.properties"},
      {"pattern":"application.*\\.yml"},
      {"pattern":"META-INF/services/.*"},
      {"pattern":"META-INF/spring/.*"}
    ]
  }
}

Pitfall 7 – Hibernate bytecode enhancement failure

Disable enhancement and use explicit JOIN FETCH:

spring:
  jpa:
    properties:
      hibernate:
        bytecode:
          provider: none
    open-in-view: false

Part 4: Docker multi‑stage build

Stage 1 (builder) uses graalvm/native-image:21 to compile the native binary (≈8 min, ~2 GB image). Stage 2 (runtime) uses debian:bookworm-slim, copies only the binary, creates a non‑root user, and runs ./order-query-service. Final image size is 87 MB versus 298 MB before.

Multi‑stage build reduces runtime image size by ~70 %.

Part 5: K8s scaling comparison

When HPA triggers, a JVM pod becomes ready after ~15 s, while a Native Image pod is ready after ~3 s, cutting the wait time by 80 %.

Part 6: Full performance data

Startup time: JVM 12.3 s → Native 0.089 s (‑99 %)

Pod Ready: 15 s → 3 s (‑80 %)

Memory (idle): 380 MB → 58 MB (‑85 %)

Docker image: 298 MB → 87 MB (‑71 %)

Stable QPS (200 concurrency): 4,800 → 3,900 (‑19 %)

P99 latency: 18 ms → 21 ms (+17 %)

Note: Native throughput is slightly lower because JVM JIT continues to optimise hot code at runtime, whereas Native Image uses generic compile‑time optimisations.

Part 7: CI/CD integration

CI pipeline runs JVM unit tests (~2 min) on every PR for fast feedback. Native Image build (8‑12 min) is triggered only on merge to main, after which the Docker image is pushed and deployed to K8s.

PR stage runs only JVM tests; full Native build runs on the release channel.

Part 8: When to use Native Image

Recommended for serverless/FaaS cold‑start, K8s microservices that need rapid scaling, CLI tools, and batch jobs. Caution for medium‑concurrency web services, memory‑constrained environments, legacy systems with heavy reflection, or long‑running high‑throughput services.

Matrix: top‑left (high start‑up sensitivity + short‑lived) = best for Native; bottom‑right (low start‑up sensitivity + long‑running) = prefer JVM.

Conclusion

By converting the order‑query service to a GraalVM Native Image we reduced pod start‑up from 15 s to 3 s, memory from 380 MB to 58 MB, and Docker image size from 298 MB to 87 MB, eliminating K8s scaling timeouts. Key lessons: address reflection‑related issues first, use GraalVM Reachability Metadata Repository, keep local development on JVM and compile Native only in CI.

DockerPerformance optimizationCI/CDKubernetesSpring BootGraalVMnative-image
Java Web Project
Written by

Java Web Project

Focused on Java backend technologies, trending internet tech, and the latest industry developments. The platform serves over 200,000 Java developers, inviting you to learn and exchange ideas together. Check the menu for Java learning resources.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.