{"schemaVersion":1,"id":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup","url":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup","title":"GraalVM Native Image: From 8-Second Spring Boot Startup to 50ms","description":"AOT compilation for Spring Boot, reflection configuration challenges, memory footprint analysis, and production trade-offs from six months of operational experience.","language":"en","datePublished":"2025-10-16","dateModified":null,"contentFormat":"text/markdown","content":"JVM\n\n# GraalVM Native Image: From 8-Second Spring Boot Startup to 50ms\n\n2025-10-167 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/graalvm-native-image-spring-boot-startup>)\n\n## The AOT compilation pipeline\n\n[GraalVM](<https://www.graalvm.org/>) Native Image performs ahead-of-time compilation, converting your Java bytecode into a standalone native executable. Unlike the JVM, which interprets bytecode and JIT-compiles hot methods at runtime, native image does all compilation at build time through a process called static analysis.\n\nThe pipeline works like this:\n\n1. **Points-to analysis**, Determines which classes, methods, and fields are reachable from the entry point\n2. **Initialization**, Runs class static initializers (at build time or deferred to runtime)\n3. **Compilation**, Compiles reachable methods to native code using the Graal compiler\n4. **Linking**, Produces a standalone executable with the SubstrateVM runtime\n\n```bash\n# Basic native image build for a Spring Boot app\n./mvnw -Pnative native:compile\n\n# Or with Gradle\n./gradlew nativeCompile\n```\n\nSpring Boot 3.x includes native image support via Spring AOT (Ahead-of-Time) processing. During build, Spring AOT analyzes your application context and generates:\n\n- Reflection hints for beans and configurations\n- Proxy class registrations\n- Serialization metadata\n- Resource registration patterns\n\n```java\n// build.gradle.kts\nplugins {\n id(\"org.graalvm.buildtools.native\") version \"0.10.4\"\n}\n\ngraalvmNative {\n binaries {\n named(\"main\") {\n imageName.set(\"auth-service\")\n mainClass.set(\"com.example.AuthServiceApplication\")\n buildArgs.add(\"-O2\")\n buildArgs.add(\"-march=native\")\n buildArgs.add(\"--gc=G1\")\n buildArgs.add(\"-H:+ReportExceptionStackTraces\")\n javaLauncher.set(javaToolchains.launcherFor {\n languageVersion.set(JavaLanguageVersion.of(21))\n vendor.set(JvmVendorSpec.GRAAL_VM)\n })\n }\n }\n}\n```\n\n## The reflection configuration nightmare\n\nJava's reflection API, ` Class.forName() `, ` Method.invoke() `, ` Field.get() `, is everywhere. Spring uses it for dependency injection, Jackson for JSON serialization, Hibernate for entity mapping. Native image's static analysis can't see through reflection, so every reflective access must be declared upfront.\n\nThe ` native-image-agent ` is your best friend here. It's a JVM agent that records all reflective access, JNI calls, proxy creation, and resource loading during runtime:\n\n```bash\n# Run your app with the tracing agent\njava -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image \\\n -jar target/auth-service.jar\n\n# Now exercise every code path, run your full test suite\n./run-integration-tests.sh\n\n# The agent generates these files:\n# reflect-config.json\n# jni-config.json\n# proxy-config.json\n# resource-config.json\n# serialization-config.json\n```\n\nThe generated ` reflect-config.json ` looks like this:\n\n```json\n[\n {\n \"name\": \"com.example.model.User\",\n \"allDeclaredConstructors\": true,\n \"allPublicMethods\": true,\n \"allDeclaredFields\": true\n },\n {\n \"name\": \"com.example.config.SecurityConfig$$SpringCGLIB$$0\",\n \"allDeclaredConstructors\": true,\n \"allPublicMethods\": true\n },\n {\n \"name\": \"com.fasterxml.jackson.databind.ser.std.ToStringSerializer\",\n \"methods\": [\n { \"name\": \"<init>\", \"parameterTypes\": [] }\n ]\n }\n]\n```\n\nThe problem: the agent only records code paths that actually execute during tracing. Miss a code path, and a ` NoSuchMethodException ` or ` ClassNotFoundException ` appears in production. A common example: a token refresh endpoint, which only triggers when tokens are near expiry, throwing a reflection error two weeks after deployment.\n\nA robust approach: run the agent against three different scenarios:\n\n```bash\n# 1. Full integration test suite\njava -agentlib:native-image-agent=config-merge-dir=native-config \\\n -jar app.jar &\n./run-integration-tests.sh\nkill %1\n\n# 2. Manual smoke test of less-tested paths\njava -agentlib:native-image-agent=config-merge-dir=native-config \\\n -jar app.jar &\ncurl -X POST localhost:8080/auth/refresh -H \"Authorization: Bearer expired_token\"\ncurl -X POST localhost:8080/auth/logout\ncurl -X GET localhost:8080/actuator/health\ncurl -X GET localhost:8080/actuator/prometheus\nkill %1\n\n# 3. Error paths, trigger validation errors, 404s, auth failures\njava -agentlib:native-image-agent=config-merge-dir=native-config \\\n -jar app.jar &\ncurl -X POST localhost:8080/auth/login -d '{\"bad\":\"json\"}'\ncurl -X GET localhost:8080/nonexistent\ncurl -X POST localhost:8080/auth/login -d '{\"email\":\"x\",\"password\":\"wrong\"}'\nkill %1\n```\n\nThe ` config-merge-dir ` flag is critical, it merges new entries with existing config instead of overwriting.\n\n## Startup time: the before and after\n\nHere are the real numbers from our auth-service:\n\n```\nJVM (OpenJDK 21, default settings):\n Startup: 8,247ms\n Time to first request: 9,100ms\n RSS at idle: 287MB\n RSS under load: 412MB\n\nNative Image (GraalVM 21, -O2, G1 GC):\n Startup: 47ms\n Time to first request: 52ms\n RSS at idle: 43MB\n RSS under load: 198MB\n```\n\nThat's a 175x improvement in startup time. But notice something: under load, the memory gap narrows from 6.7x to 2.1x. The native image's advantage is primarily in idle/startup scenarios. For long-running services under sustained load, the JVM's C2 JIT compiler produces more optimized code for hot paths.\n\nThroughput benchmarks for the same service handling JWT validation:\n\n```\nJVM (after warmup): 47,200 req/s at p99=2.1ms\nNative Image: 31,400 req/s at p99=3.8ms\n```\n\nThe JVM is 50% faster at peak throughput. This is the fundamental trade-off: native image gives you fast startup and low idle memory at the cost of peak throughput. For our auth service (50 req/min), the throughput difference is irrelevant. For a high-traffic API gateway, it might matter.\n\n## Build time and CI/CD impact\n\nNative image compilation is brutally resource-intensive. Our auth-service (a small Spring Boot app with ~30 beans) takes:\n\n```\nJVM build (mvn package): 14 seconds, 512MB RAM\nNative image build (-O2): 11 minutes 23 seconds, 14.2GB RAM\nNative image build (-Ob): 4 minutes 8 seconds, 8.1GB RAM\n```\n\nCI runners need beefy machines. ` c6a.2xlarge ` instances (8 vCPU, 16GB RAM) are a reasonable baseline for native builds. The ` -Ob ` (quick build) flag is essential for development, it skips expensive optimizations and produces a larger, slightly slower binary, but builds 3x faster.\n\nHere's our multi-stage Dockerfile:\n\n```dockerfile\n# Stage 1: Build native image\nFROM ghcr.io/graalvm/native-image-community:21 AS builder\n\nWORKDIR /app\nCOPY . .\n\nRUN ./mvnw -Pnative native:compile -DskipTests \\\n -Dspring.native.build.args=\"-O2 --gc=G1 -march=x86-64-v3\"\n\n# Stage 2: Minimal runtime image\nFROM debian:bookworm-slim\n\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n libz-dev \\\n && rm -rf /var/lib/apt/lists/*\n\nCOPY --from=builder /app/target/auth-service /app/auth-service\n\nEXPOSE 8080\nENTRYPOINT [\"/app/auth-service\"]\n```\n\nThe final image is 82MB, compared to 340MB for the JVM-based image with a JDK. In a Kubernetes cluster running 40 instances, that's 10GB less to pull on deployments.\n\n## Memory footprint: the hidden complexity\n\nRSS (Resident Set Size) is what matters, not just heap. A native image's memory consists of:\n\n1. **Image heap**, Objects initialized at build time, mapped into memory\n2. **Runtime heap**, Objects allocated at runtime (managed by GC)\n3. **Code cache**, Compiled native code\n4. **Thread stacks**, Default 1MB per thread\n5. **Native memory**, JNI allocations, NIO buffers\n\n```bash\n# Monitor native image memory breakdown\nauth-service -XX:+PrintGC -XX:+VerboseGC \\\n -Xmx128m \\\n -XX:MaxDirectMemorySize=64m \\\n -Dserver.tomcat.threads.max=50\n```\n\nThe gotcha: image heap is memory-mapped from the executable file, so it shows up in RSS but is shared across processes and doesn't consume physical RAM proportionally. Tools like ` pmap ` give you better visibility:\n\n```bash\n# Detailed memory map\npmap -x $(pgrep auth-service) | tail -5\n\n# Or use NativeMemoryTracking equivalent\nauth-service -XX:+DashboardAll -XX:DashboardDump=heap-info.json\n```\n\n## Production lessons\n\nAfter six months of production experience, the key takeaways for teams considering GraalVM Native Image:\n\n**Use it for**: CLI tools, serverless functions, short-lived batch jobs, low-traffic microservices, sidecar containers, and any workload where startup time or idle memory matters more than peak throughput.\n\n**Avoid it for**: high-throughput services where JIT optimization matters, apps with heavy use of dynamic class loading (plugins, OSGI), services that depend on libraries without native image support.\n\n**The reflection problem doesn't go away**: Even with Spring AOT, third-party libraries surprise you. Budget time for tracing agent runs after every dependency upgrade.\n\n**Build reproducibility**: Pin your GraalVM version. Different versions produce different binaries with different performance characteristics. We pin to exact patch versions in our build toolchain.\n\n**Monitoring**: The JMX MBeans you rely on for JVM monitoring don't exist in native image. VisualVM doesn't connect. You need to instrument your application differently, Micrometer with Prometheus works, but Java Flight Recorder doesn't.\n\nAn auth service running on native images for six months demonstrates the benefits clearly. Deployments that previously took 45 seconds (readiness probe wait) now complete in under 3 seconds. Container sizes drop from 512MB to 256MB with headroom. The throughput difference is invisible at typical traffic levels. For this specific workload, native image is an unqualified win. For others, the answer is more nuanced, and that nuance is the whole point of this article.\n\n## Frequently asked questions\n\n### Is GraalVM Native Image production-ready for Spring Boot?\n\nYes, Spring Boot 3.x has first-class native image support via Spring AOT. Most Spring modules work out of the box, though some third-party libraries require manual reflection configuration.\n\n### What are the main trade-offs of native compilation?\n\nFaster startup and lower memory at the cost of lower peak throughput (no JIT optimization), longer build times, and restricted use of reflection/dynamic class loading. The C2 JIT compiler can optimize hot paths better than AOT for long-running services.\n\n### How much memory does a native Spring Boot app use?\n\nA typical REST API starts at 30-60MB RSS compared to 150-300MB for the same app on the JVM. However, under load, the gap narrows because the native image allocates heap similarly to the JVM.\n\n## Tips\n\n1. **Tip:** Run ` native-image --initialize-at-build-time ` reporting with ` -H:+PrintClassInitialization ` before your first build. It will show you exactly which classes are delaying startup, and you can decide which to initialize at build time vs runtime.\n2. **Tip:** Never ship a native image without running the ` native-image-agent ` against your full integration test suite. Missing reflection configs only show up at runtime as ` NoSuchMethodException ` in production, the worst kind of surprise.\n3. **Tip:** Use ` -Ob ` (quick build mode) during development and ` -O2 ` only for release builds. Full optimization takes 10-15 minutes and 14GB+ RAM, your dev loop can't afford that.\n4. **Tip:** Monitor RSS memory, not heap. Native images use off-heap memory for image-mapped data, code cache, and thread stacks. A 128MB heap app might consume 350MB RSS in production.\n\n- graalvm\n- native-image\n- spring-boot\n- aot\n- jvm\n- startup-time\n- performance\n- java\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"GraalVM Native Image: From 8-Second Spring Boot Startup to 50ms","url":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup#title"},{"level":2,"text":"The AOT compilation pipeline","url":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup#the-aot-compilation-pipeline"},{"level":2,"text":"The reflection configuration nightmare","url":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup#the-reflection-configuration-nightmare"},{"level":2,"text":"Startup time: the before and after","url":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup#startup-time-the-before-and-after"},{"level":2,"text":"Build time and CI/CD impact","url":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup#build-time-and-cicd-impact"},{"level":2,"text":"Memory footprint: the hidden complexity","url":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup#memory-footprint-the-hidden-complexity"},{"level":2,"text":"Production lessons","url":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup#production-lessons"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup#questions"},{"level":3,"text":"Is GraalVM Native Image production-ready for Spring Boot?","url":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup#question-1"},{"level":3,"text":"What are the main trade-offs of native compilation?","url":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup#question-2"},{"level":3,"text":"How much memory does a native Spring Boot app use?","url":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup#article-tips"}],"sections":[{"heading":"GraalVM Native Image: From 8-Second Spring Boot Startup to 50ms","level":1,"url":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup","content":"JVM"},{"heading":"GraalVM Native Image: From 8-Second Spring Boot Startup to 50ms","level":1,"url":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup#title","content":"2025-10-167 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/graalvm-native-image-spring-boot-startup>)"},{"heading":"The AOT compilation pipeline","level":2,"url":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup#the-aot-compilation-pipeline","content":"[GraalVM](<https://www.graalvm.org/>) Native Image performs ahead-of-time compilation, converting your Java bytecode into a standalone native executable. Unlike the JVM, which interprets bytecode and JIT-compiles hot methods at runtime, native image does all compilation at build time through a process called static analysis.\n\nThe pipeline works like this:\n\n1. **Points-to analysis**, Determines which classes, methods, and fields are reachable from the entry point\n2. **Initialization**, Runs class static initializers (at build time or deferred to runtime)\n3. **Compilation**, Compiles reachable methods to native code using the Graal compiler\n4. **Linking**, Produces a standalone executable with the SubstrateVM runtime\n\n```bash\n# Basic native image build for a Spring Boot app\n./mvnw -Pnative native:compile\n\n# Or with Gradle\n./gradlew nativeCompile\n```\n\nSpring Boot 3.x includes native image support via Spring AOT (Ahead-of-Time) processing. During build, Spring AOT analyzes your application context and generates:\n\n- Reflection hints for beans and configurations\n- Proxy class registrations\n- Serialization metadata\n- Resource registration patterns\n\n```java\n// build.gradle.kts\nplugins {\n id(\"org.graalvm.buildtools.native\") version \"0.10.4\"\n}\n\ngraalvmNative {\n binaries {\n named(\"main\") {\n imageName.set(\"auth-service\")\n mainClass.set(\"com.example.AuthServiceApplication\")\n buildArgs.add(\"-O2\")\n buildArgs.add(\"-march=native\")\n buildArgs.add(\"--gc=G1\")\n buildArgs.add(\"-H:+ReportExceptionStackTraces\")\n javaLauncher.set(javaToolchains.launcherFor {\n languageVersion.set(JavaLanguageVersion.of(21))\n vendor.set(JvmVendorSpec.GRAAL_VM)\n })\n }\n }\n}\n```"},{"heading":"The reflection configuration nightmare","level":2,"url":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup#the-reflection-configuration-nightmare","content":"Java's reflection API, ` Class.forName() `, ` Method.invoke() `, ` Field.get() `, is everywhere. Spring uses it for dependency injection, Jackson for JSON serialization, Hibernate for entity mapping. Native image's static analysis can't see through reflection, so every reflective access must be declared upfront.\n\nThe ` native-image-agent ` is your best friend here. It's a JVM agent that records all reflective access, JNI calls, proxy creation, and resource loading during runtime:\n\n```bash\n# Run your app with the tracing agent\njava -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image \\\n -jar target/auth-service.jar\n\n# Now exercise every code path, run your full test suite\n./run-integration-tests.sh\n\n# The agent generates these files:\n# reflect-config.json\n# jni-config.json\n# proxy-config.json\n# resource-config.json\n# serialization-config.json\n```\n\nThe generated ` reflect-config.json ` looks like this:\n\n```json\n[\n {\n \"name\": \"com.example.model.User\",\n \"allDeclaredConstructors\": true,\n \"allPublicMethods\": true,\n \"allDeclaredFields\": true\n },\n {\n \"name\": \"com.example.config.SecurityConfig$$SpringCGLIB$$0\",\n \"allDeclaredConstructors\": true,\n \"allPublicMethods\": true\n },\n {\n \"name\": \"com.fasterxml.jackson.databind.ser.std.ToStringSerializer\",\n \"methods\": [\n { \"name\": \"<init>\", \"parameterTypes\": [] }\n ]\n }\n]\n```\n\nThe problem: the agent only records code paths that actually execute during tracing. Miss a code path, and a ` NoSuchMethodException ` or ` ClassNotFoundException ` appears in production. A common example: a token refresh endpoint, which only triggers when tokens are near expiry, throwing a reflection error two weeks after deployment.\n\nA robust approach: run the agent against three different scenarios:\n\n```bash\n# 1. Full integration test suite\njava -agentlib:native-image-agent=config-merge-dir=native-config \\\n -jar app.jar &\n./run-integration-tests.sh\nkill %1\n\n# 2. Manual smoke test of less-tested paths\njava -agentlib:native-image-agent=config-merge-dir=native-config \\\n -jar app.jar &\ncurl -X POST localhost:8080/auth/refresh -H \"Authorization: Bearer expired_token\"\ncurl -X POST localhost:8080/auth/logout\ncurl -X GET localhost:8080/actuator/health\ncurl -X GET localhost:8080/actuator/prometheus\nkill %1\n\n# 3. Error paths, trigger validation errors, 404s, auth failures\njava -agentlib:native-image-agent=config-merge-dir=native-config \\\n -jar app.jar &\ncurl -X POST localhost:8080/auth/login -d '{\"bad\":\"json\"}'\ncurl -X GET localhost:8080/nonexistent\ncurl -X POST localhost:8080/auth/login -d '{\"email\":\"x\",\"password\":\"wrong\"}'\nkill %1\n```\n\nThe ` config-merge-dir ` flag is critical, it merges new entries with existing config instead of overwriting."},{"heading":"Startup time: the before and after","level":2,"url":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup#startup-time-the-before-and-after","content":"Here are the real numbers from our auth-service:\n\n```\nJVM (OpenJDK 21, default settings):\n Startup: 8,247ms\n Time to first request: 9,100ms\n RSS at idle: 287MB\n RSS under load: 412MB\n\nNative Image (GraalVM 21, -O2, G1 GC):\n Startup: 47ms\n Time to first request: 52ms\n RSS at idle: 43MB\n RSS under load: 198MB\n```\n\nThat's a 175x improvement in startup time. But notice something: under load, the memory gap narrows from 6.7x to 2.1x. The native image's advantage is primarily in idle/startup scenarios. For long-running services under sustained load, the JVM's C2 JIT compiler produces more optimized code for hot paths.\n\nThroughput benchmarks for the same service handling JWT validation:\n\n```\nJVM (after warmup): 47,200 req/s at p99=2.1ms\nNative Image: 31,400 req/s at p99=3.8ms\n```\n\nThe JVM is 50% faster at peak throughput. This is the fundamental trade-off: native image gives you fast startup and low idle memory at the cost of peak throughput. For our auth service (50 req/min), the throughput difference is irrelevant. For a high-traffic API gateway, it might matter."},{"heading":"Build time and CI/CD impact","level":2,"url":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup#build-time-and-cicd-impact","content":"Native image compilation is brutally resource-intensive. Our auth-service (a small Spring Boot app with ~30 beans) takes:\n\n```\nJVM build (mvn package): 14 seconds, 512MB RAM\nNative image build (-O2): 11 minutes 23 seconds, 14.2GB RAM\nNative image build (-Ob): 4 minutes 8 seconds, 8.1GB RAM\n```\n\nCI runners need beefy machines. ` c6a.2xlarge ` instances (8 vCPU, 16GB RAM) are a reasonable baseline for native builds. The ` -Ob ` (quick build) flag is essential for development, it skips expensive optimizations and produces a larger, slightly slower binary, but builds 3x faster.\n\nHere's our multi-stage Dockerfile:\n\n```dockerfile\n# Stage 1: Build native image\nFROM ghcr.io/graalvm/native-image-community:21 AS builder\n\nWORKDIR /app\nCOPY . .\n\nRUN ./mvnw -Pnative native:compile -DskipTests \\\n -Dspring.native.build.args=\"-O2 --gc=G1 -march=x86-64-v3\"\n\n# Stage 2: Minimal runtime image\nFROM debian:bookworm-slim\n\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n libz-dev \\\n && rm -rf /var/lib/apt/lists/*\n\nCOPY --from=builder /app/target/auth-service /app/auth-service\n\nEXPOSE 8080\nENTRYPOINT [\"/app/auth-service\"]\n```\n\nThe final image is 82MB, compared to 340MB for the JVM-based image with a JDK. In a Kubernetes cluster running 40 instances, that's 10GB less to pull on deployments."},{"heading":"Memory footprint: the hidden complexity","level":2,"url":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup#memory-footprint-the-hidden-complexity","content":"RSS (Resident Set Size) is what matters, not just heap. A native image's memory consists of:\n\n1. **Image heap**, Objects initialized at build time, mapped into memory\n2. **Runtime heap**, Objects allocated at runtime (managed by GC)\n3. **Code cache**, Compiled native code\n4. **Thread stacks**, Default 1MB per thread\n5. **Native memory**, JNI allocations, NIO buffers\n\n```bash\n# Monitor native image memory breakdown\nauth-service -XX:+PrintGC -XX:+VerboseGC \\\n -Xmx128m \\\n -XX:MaxDirectMemorySize=64m \\\n -Dserver.tomcat.threads.max=50\n```\n\nThe gotcha: image heap is memory-mapped from the executable file, so it shows up in RSS but is shared across processes and doesn't consume physical RAM proportionally. Tools like ` pmap ` give you better visibility:\n\n```bash\n# Detailed memory map\npmap -x $(pgrep auth-service) | tail -5\n\n# Or use NativeMemoryTracking equivalent\nauth-service -XX:+DashboardAll -XX:DashboardDump=heap-info.json\n```"},{"heading":"Production lessons","level":2,"url":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup#production-lessons","content":"After six months of production experience, the key takeaways for teams considering GraalVM Native Image:\n\n**Use it for**: CLI tools, serverless functions, short-lived batch jobs, low-traffic microservices, sidecar containers, and any workload where startup time or idle memory matters more than peak throughput.\n\n**Avoid it for**: high-throughput services where JIT optimization matters, apps with heavy use of dynamic class loading (plugins, OSGI), services that depend on libraries without native image support.\n\n**The reflection problem doesn't go away**: Even with Spring AOT, third-party libraries surprise you. Budget time for tracing agent runs after every dependency upgrade.\n\n**Build reproducibility**: Pin your GraalVM version. Different versions produce different binaries with different performance characteristics. We pin to exact patch versions in our build toolchain.\n\n**Monitoring**: The JMX MBeans you rely on for JVM monitoring don't exist in native image. VisualVM doesn't connect. You need to instrument your application differently, Micrometer with Prometheus works, but Java Flight Recorder doesn't.\n\nAn auth service running on native images for six months demonstrates the benefits clearly. Deployments that previously took 45 seconds (readiness probe wait) now complete in under 3 seconds. Container sizes drop from 512MB to 256MB with headroom. The throughput difference is invisible at typical traffic levels. For this specific workload, native image is an unqualified win. For others, the answer is more nuanced, and that nuance is the whole point of this article."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup#questions","content":""},{"heading":"Is GraalVM Native Image production-ready for Spring Boot?","level":3,"url":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup#question-1","content":"Yes, Spring Boot 3.x has first-class native image support via Spring AOT. Most Spring modules work out of the box, though some third-party libraries require manual reflection configuration."},{"heading":"What are the main trade-offs of native compilation?","level":3,"url":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup#question-2","content":"Faster startup and lower memory at the cost of lower peak throughput (no JIT optimization), longer build times, and restricted use of reflection/dynamic class loading. The C2 JIT compiler can optimize hot paths better than AOT for long-running services."},{"heading":"How much memory does a native Spring Boot app use?","level":3,"url":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup#question-3","content":"A typical REST API starts at 30-60MB RSS compared to 150-300MB for the same app on the JVM. However, under load, the gap narrows because the native image allocates heap similarly to the JVM."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup#article-tips","content":"1. **Tip:** Run ` native-image --initialize-at-build-time ` reporting with ` -H:+PrintClassInitialization ` before your first build. It will show you exactly which classes are delaying startup, and you can decide which to initialize at build time vs runtime.\n2. **Tip:** Never ship a native image without running the ` native-image-agent ` against your full integration test suite. Missing reflection configs only show up at runtime as ` NoSuchMethodException ` in production, the worst kind of surprise.\n3. **Tip:** Use ` -Ob ` (quick build mode) during development and ` -O2 ` only for release builds. Full optimization takes 10-15 minutes and 14GB+ RAM, your dev loop can't afford that.\n4. **Tip:** Monitor RSS memory, not heap. Native images use off-heap memory for image-mapped data, code cache, and thread stacks. A 128MB heap app might consume 350MB RSS in production.\n\n- graalvm\n- native-image\n- spring-boot\n- aot\n- jvm\n- startup-time\n- performance\n- java\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Español","url":"https://lucioduran.com/es/blog/graalvm-native-image-spring-boot-startup"},{"text":"GraalVM","url":"https://www.graalvm.org/"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup"},"headline":"GraalVM Native Image: From 8-Second Spring Boot Startup to 50ms","description":"AOT compilation for Spring Boot, reflection configuration challenges, memory footprint analysis, and production trade-offs from six months of operational experience.","datePublished":"2025-10-16","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://lucioduran.com/favicons/og-default.png","url":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"JVM","keywords":"graalvm, native-image, spring-boot, aot, jvm, startup-time, performance, java","wordCount":1193,"timeRequired":"PT7M","workTranslation":[{"@type":"BlogPosting","inLanguage":"es","url":"https://lucioduran.com/es/blog/graalvm-native-image-spring-boot-startup"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"GraalVM Native Image: From 8-Second Spring Boot Startup to 50ms","item":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Is GraalVM Native Image production-ready for Spring Boot?","acceptedAnswer":{"@type":"Answer","text":"Yes, Spring Boot 3.x has first-class native image support via Spring AOT. Most Spring modules work out of the box, though some third-party libraries require manual reflection configuration."}},{"@type":"Question","name":"What are the main trade-offs of native compilation?","acceptedAnswer":{"@type":"Answer","text":"Faster startup and lower memory at the cost of lower peak throughput (no JIT optimization), longer build times, and restricted use of reflection/dynamic class loading. The C2 JIT compiler can optimize hot paths better than AOT for long-running services."}},{"@type":"Question","name":"How much memory does a native Spring Boot app use?","acceptedAnswer":{"@type":"Answer","text":"A typical REST API starts at 30-60MB RSS compared to 150-300MB for the same app on the JVM. However, under load, the gap narrows because the native image allocates heap similarly to the JVM."}}]}]}],"contentHash":"da4ebf9cd17d6ff7420d67d884dee60f1579c29d5c6778d6b61e1e817848f538","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":59945,"contentBytes":11638}}
