How to reduce Java Lambda cold start times using GraalVM Native Image and Lambda SnapStart — what each approach costs, what it gains, and when to use which.
The cold start problem with Java on Lambda is real and has been overstated in equal measure. A Java Lambda running Spring Boot with JVM startup can take 8–15 seconds on a cold start. For low-traffic functions or background processing, this is irrelevant. For user-facing APIs, it is unacceptable. Two solutions exist: GraalVM native compilation and Lambda SnapStart. They solve the same problem with different trade-offs.
A cold start has three phases:
For a simple function with no framework, phase 2 takes ~50ms. For a Spring Boot function, phase 3 can take 8+ seconds on the first request. This is the target for optimisation.
SnapStart (available for Java runtimes since 2022) takes a snapshot of the initialised JVM state after the function initialises and before the first invocation. On subsequent cold starts, Lambda restores the snapshot rather than re-initialising — effectively caching the startup cost.
Enable it in the Lambda configuration:
// CDK
Function.Builder.create(this, "TradingFunction")
.runtime(Runtime.JAVA_21)
.handler("com.trinitylogic.TradingHandler::handleRequest")
.snapStart(SnapStartConf.ON_PUBLISHED_VERSIONS)
.code(Code.fromAsset("target/function.zip"))
.build();
SnapStart only works on published versions — you must create a Lambda version (not use $LATEST) for SnapStart to activate.
For a Spring Boot function with HikariCP and Jackson:
This is a dramatic improvement with zero code changes.
The snapshot is taken once and restored many times. Any state that must be unique per instance (TLS session IDs, random seeds, timestamps captured at startup) will be identical across all restored instances unless you re-initialise them. AWS provides hooks for this:
@Component
public class SnapStartRestoreHook implements CRaC.Resource {
@Override
public void beforeCheckpoint(Context<? extends Resource> context) {
// Close connections before snapshot (they will be stale on restore)
dataSource.getHikariPoolMXBean().softEvictConnections();
}
@Override
public void afterRestore(Context<? extends Resource> context) {
// Re-establish connections after restore
dataSource.getConnection().close(); // warm up pool
SecureRandom.getInstanceStrong().nextBytes(new byte[1]); // re-seed RNG
}
}
Add the CRaC dependency:
<dependency>
<groupId>io.github.crac</groupId>
<artifactId>org-crac</artifactId>
<version>0.1.3</version>
</dependency>
Native compilation compiles your Java application to a self-contained native executable at build time. No JVM starts at runtime — the binary executes directly. Cold starts drop to 50–100ms.
Add the Spring Boot native support:
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<configuration>
<buildArgs>
<buildArg>--no-fallback</buildArg>
<buildArg>--enable-url-protocols=https</buildArg>
</buildArgs>
</configuration>
</plugin>
Build the native executable:
mvn -Pnative native:compile
This takes 3–10 minutes. The output is a Linux x86-64 binary if built on a matching host (use Docker for cross-compilation):
FROM ghcr.io/graalvm/native-image:21 AS builder
WORKDIR /app
COPY . .
RUN mvn -Pnative native:compile -DskipTests
FROM amazonlinux:2023
COPY --from=builder /app/target/trading-function ./function
ENTRYPOINT ["./function"]
// CDK
Function.Builder.create(this, "NativeTradingFunction")
.runtime(Runtime.PROVIDED_AL2023)
.handler("not.used.for.native")
.code(Code.fromDockerBuild("."))
.build();
Compilation time: 5–10 minutes per build vs seconds for JVM. CI pipelines must allocate time and RAM (GraalVM compilation needs 6–8GB heap).
Reflection: Anything loaded via reflection at runtime must be registered at compile time. Spring’s @Autowired, Jackson’s deserialisation, JPA entity scanning — all require hint files or annotation-based configuration:
@RegisterReflectionForBinding({OrderDto.class, MarketDto.class})
@SpringBootApplication
public class TradingApplication {}
Spring Boot 3.x with Spring AOT handles most reflection hints automatically, but complex configurations still require manual @RegisterReflectionForBinding or reflect-config.json entries.
Throughput: Native executables have no JIT compiler. For short-lived Lambda invocations, this is fine. For functions that process thousands of requests per second over hours, the JVM with JIT may have higher sustained throughput.
| SnapStart | GraalVM Native | |
|---|---|---|
| Cold start | 200–500ms | 50–100ms |
| Build complexity | None | High |
| Throughput | Full JIT | No JIT |
| Reflection/frameworks | Full support | Requires hints |
| Cost | None | Build time + CI resources |
Use SnapStart if you need fast cold starts with minimal effort and full Spring Boot compatibility. It works on existing code with a CDK configuration change.
Use GraalVM if you need sub-100ms cold starts, are willing to manage build complexity, and have relatively simple or well-annotated reflection usage. Ideal for latency-critical, simple functions.
For most production Spring Boot Lambdas, SnapStart is the right first step — dramatic improvement, zero code changes. Reach for GraalVM when SnapStart’s 200ms isn’t sufficient.
If you’re optimising Java Lambda functions for production use and want help with the build pipeline, get in touch.