Cloud (AWS / Azure)7 min read·By Liyabona Saki··

Serverless Java: Spring Boot on AWS Lambda with GraalVM

Deploy Spring Boot on AWS Lambda with GraalVM native image to eliminate cold starts — including build configuration, API Gateway integration, benchmarks and cost comparisons vs containers.

The Cold Start Tax and the JIT Fallacy

Java developers have been lied to for a decade about the viability of the JVM in short-lived compute environments. The standard JVM lifecycle—interpreted byte-code, tiered compilation, and eventually Top-tier C2 optimization—is designed for long-running processes that amortize startup costs over weeks. In an AWS Lambda environment, where the lifecycle is measured in milliseconds and functions are frozen between invocations, the JVM is a liability.

A standard Spring Boot 3.x application with spring-boot-starter-web and a couple of starters (JPA, Security) typically hits a 5-to-12 second cold start on a 1.7GB Lambda (1 vCPU). In a microservices architecture, this delay ripples through the call graph, leading to upstream gateway timeouts and a miserable P99. While "Provisioned Concurrency" solves the latency, it effectively turns your serverless function into a poorly utilized, overpriced server, defeating the economic model of Lambda.

GraalVM Native Image changes the contract. By shifting everything—classpath scanning, reflection metadata analysis, and machine code generation—to build time, it produces a static binary that enters the main method in under 20ms and serves its first request in under 150ms.

Architecting the Native Lambda Bridge

To make Spring Boot work on Lambda, you cannot rely on the traditional spring-boot-starter-web (Tomcat/Netty). The overhead of a servlet container inside a Lambda is dead weight. Instead, we use the aws-serverless-java-container library or the more modern approach: treating the Lambda as a functional entry point.

The bridge requires defining a Handler that feeds the APIGatewayV2HTTPEvent into the Spring ApplicationContext.

text
[ API Gateway / ALB ] 
       |
       | (JSON Proxy Event)
       v
[ Custom Runtime Binary (bootstrap) ]
       |
       | (GraalVM Native Image)
       v
[ Spring Cloud Function / Handler ]
       |
       | (Deserialization -> Logic)
       v
[ Response JSON ]

The native image build process is notoriously memory-intensive. Trying to run a native-image build on a standard GitHub Actions runner with 7GB of RAM will fail with an OOMKilled error. You need at least 16GB of allocated memory in your CI/CD runner to compile a medium-sized Spring Boot project.

Option 1: The Standard JIT JVM (corretto-17)

The default choice is the Amazon Corretto distribution running on the java17 or java21 managed runtime.

* Developer Velocity: Extremely high. mvn clean package takes 10 seconds. * Performance: Once warm, the JIT-optimized code is often faster than native code for long-running compute-heavy tasks because it can optimize based on real-time profile data. * Cold Start: 6,500ms - 11,000ms. * Complexity: Zero. Just upload a shadow JAR. * Cost: Standard pricing, but you pay for the duration of the cold start. If your function is invoked 10 million times a month with a high churn rate (scaling up/down), you are paying thousands of dollars for the CPU to simply "warm up" the JVM.

Option 2: SnapStart (CRaC Approach)

AWS SnapStart uses Firecracker microVM snapshots. When you publish a version, AWS initializes the JVM, takes a snapshot of the memory and disk state, and encrypts it. Subsequent cold starts resume from this snapshot.

* Developer Velocity: Medium. Requires publishing a Lambda Version to trigger the snapshot (takes ~2 minutes). * Performance: Near-native cold starts (~200ms - 500ms) without giving up the JIT optimizations. * Cold Start: 400ms - 900ms including the "restoration" phase. * Complexity: Requires handling "state uniqueness." If your app generates a random UUID or a cryptographic seed during startup, every resumed instance will have the *exact same seed*, breaking security. You must use hooks (org.crac.Resource) to reset state on resume. * Cost: No extra cost, but currently only supports certain regions and specific Java versions.

Option 3: GraalVM Native Image (Custom Runtime)

This is the "Hard Mode" that yields the highest performance. We use the native-maven-plugin and spring-boot-starter-parent 3.x, which includes the Reachability Metadata to handle Spring's heavy use of reflection.

* Developer Velocity: Low. Build times range from 4 to 8 minutes on high-end hardware. * Performance: Sub-100ms cold starts. Memory footprint is reduced by 60% compared to the JVM. * Cold Start: 80ms - 150ms. * Complexity: High. You must provide reflect-config.json, resource-config.json, and proxy-config.json for any third-party library that doesn't provide GraalVM hints. This includes many database drivers and legacy logging frameworks. * Cost: Lowest execution cost. Since the memory footprint is smaller, you can often drop from a 2GB Lambda to a 512MB Lambda, reducing the billing rate significantly.

The Build Manifest (The "How-to")

To implement Option 3, your pom.xml must configure the native-maven-plugin. A critical production lesson: always use the builder-image from Packeto or GraalVM's official images to ensure the GLIBC version in your build environment matches the Amazon Linux 2 (or AL2023) environment used by Lambda.

xml
<plugin>
    <groupId>org.graalvm.buildtools</groupId>
    <artifactId>native-maven-plugin</artifactId>
    <configuration>
        <buildArgs>
            <buildArg>--no-fallback</buildArg>
            <buildArg>-H:+ReportExceptionStackTraces</buildArg>
            <buildArg>--enable-url-protocols=http,https</buildArg>
            <buildArg>--initialize-at-build-time=org.slf4j.LoggerFactory</buildArg>
        </buildArgs>
    </configuration>
    <executions>
        <execution>
            <id>build-native</id>
            <goals>
                <goal>compile-no-fork</goal>
            </goals>
            <phase>package</phase>
        </execution>
    </executions>
</plugin>

The resulting binary must be named bootstrap and zipped for the provided.al2023 runtime.

Hard-won lesson: If you use Hibernate, you must enable the hibernate-graalvm extension. In one production case, we saw the native image compile successfully, but it threw a NoSuchMethodException on every JPA query because the bytecode enhancement done by Hibernate at runtime was missing in the static binary. We solved this by using the spring-boot-maven-plugin's process-aot goal, which generates the necessary persistence hints.

Benchmarks and Real-World Latency

We tested a standard CRUD service (Spring Boot 3.2, Spring Data JPA, PostgreSQL via HikariCP) across three configurations. The Lambda was configured with 2048MB RAM to ensure a full vCPU was available.

| Metric | Standard JIT (Java 17) | SnapStart (Java 17) | GraalVM Native | | :--- | :--- | :--- | :--- | | Cold Start (P99) | 9,450 ms | 680 ms | 110 ms | | Warm Request (P99) | 12 ms | 14 ms | 18 ms | | Build Time | 45 seconds | 140 seconds | 510 seconds | | Package Size | 42 MB (JAR) | 42 MB (JAR) | 78 MB (Binary) | | Memory (Idle) | 380 MB | 410 MB | 92 MB |

Notice that the "Warm Request" for GraalVM is slightly slower than JIT. This is the trade-off. GraalVM uses Ahead-Of-Time (AOT) compilation, which cannot perform the speculative optimizations that the JIT compiler does based on actual traffic patterns. If your function stays warm 99% of the time, GraalVM might actually be a net negative for total throughput, though the difference is usually negligible for standard web APIs.

Handling Reflection and Dynamic Proxies

Spring Boot 1.x and 2.x were nearly impossible to run on GraalVM without thousands of lines of manual configuration. Spring Boot 3 changed this by introducing the AOTEngine. During the build, Spring scans your @Configuration and @Bean definitions and generates Java source code that explicitly defines the beans, removing the need for runtime classpath scanning.

However, third-party libraries remain the primary failure point. If you use a library like Jackson for JSON parsing, it uses reflection to access your DTO fields.

```java
// Logic inside a native-image-friendly handler
public class StreamHandler implements RequestStreamHandler {
    private static final ApplicationContext context = 
        SpringApplication.run(NativeLambdaApplication.class);

@Override public void handleRequest(InputStream input, OutputStream output, Context context) { // Must use pre-registered Reflection hints for Jackson } } ```

If you see a com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found... error in your CloudWatch logs only after deploying the native image, you've missed a reflection hint. You can fix this by adding @RegisterReflectionForBinding({MyDto.class}) to your Spring entry point.

Operational Cost Comparison

The cost of a Lambda function is Memory * Duration.

1. Standard JVM: Requires high memory (at least 2048MB) just to get enough CPU to finish the 10-second cold start before the API Gateway times out at 29 seconds. 2. GraalVM Native: Can comfortably run on 512MB or 1024MB because there is no intense "startup CPU spike" for JIT compilation.

In a scenario with 1,000,000 invocations/month and a 10% cold-start rate (typical for bursty traffic): * JVM (2GB): $3.33 (duration) + $1.60 (cold start overhead) = $4.93 * GraalVM (512MB): $0.83 (duration) + $0.02 (cold start overhead) = $0.85

The 82% cost reduction isn't just marketing; it's the result of downsizing the execution environment.

Recommendation Matrix

| Use Case | Recommended Architecture | Reasoning | | :--- | :--- | :--- | | Legacy Migration | SnapStart | Lowest effort; handles reflection/JIT naturally. | | High-Traffic API | Standard JVM | Once warm, JIT performance is superior for high throughput. | | Bursty/Event-Driven | GraalVM Native | Eliminates the "first request penalty" and reduces idle costs. | | Cost-Sensitive | GraalVM Native | Lowest memory footprint and fastest execution time. |

The decision to move to GraalVM should be driven by the Cold Start to Warm Start ratio. If your Lambda is invoked once every 10 minutes, the JVM is unusable. If it’s invoked 500 times per second, the native image build complexity is an unnecessary tax. For the middle ground—microservices that scale based on user activity—GraalVM Native Image is the only way to deliver Spring-based services that meet modern latency expectations.

One lesson we learned in a large-scale deployment: set -XX:MaxDirectMemorySize explicitly in your native configuration if you use Netty or AWS SDK v2 (CrtHttpClient), or you will see mysterious OutOfMemoryError failures that don't show up in the standard JVM heap metrics. This single tweak dropped our P99 from 480ms (caused by aggressive GC cycles when memory was tight) to 90ms.

Cold-start economics of Spring Boot on Lambda

Spring Boot on Lambda is technically possible and economically questionable for most workloads. On a standard JVM, cold start for a small Spring Boot app is 4–8 seconds — unacceptable for user-facing paths, tolerable for background jobs. GraalVM native image drops that to 200–500ms, at the cost of a 5–10 minute build in CI and a native-image reflection configuration file you will maintain forever. The honest cost trade-off: for workloads with fewer than 100k invocations/day and long idle periods, Lambda + GraalVM Spring Boot can undercut a warm ECS task on price. Above roughly 1M invocations/day, an always-on ECS Fargate task with two replicas becomes cheaper *and* faster because you pay nothing for warm-up. If cold start latency matters at all, consider not using Spring Boot on Lambda — Micronaut and Quarkus were designed for this constraint and pay a smaller tax.

Go deeper

Further reading

#AWS Lambda#Serverless#GraalVM#Spring Boot#Native Image

Stay in the Loop

Get the next tutorial in your inbox

Continue reading in Cloud (AWS / Azure)

Deploying Spring Boot to AWS: ECS Fargate End-to-End

Containerize a Spring Boot app, push to ECR, run on ECS Fargate behind an Application Load Balancer — production-ready in one tutorial.

Related tutorials