Deploying Spring Boot to AWS Lambda and tackling cold start latency — GraalVM native image, SnapStart, tiered compilation, and knowing when Lambda is the wrong choice for Java workloads.
AWS Lambda is a natural fit for event-driven architectures — and it’s where I’ve deployed several components of financial data ingestion pipelines and AWS-hosted services. The friction comes from Java’s cold start time. A standard Spring Boot application with its full context can take 8–15 seconds to initialise on a fresh Lambda container. For APIs where users experience that latency, it’s unacceptable. The good news is there are several techniques that bring cold starts to under 1 second, and in many cases the choice of approach is straightforward.
Lambda cold start time breaks down into:
Step 3 is the main culprit. Spring Boot on the JVM needs to load and initialise hundreds of beans, run auto-configuration, and connect to external dependencies. On a warmed Lambda with an already-running JVM, this cost is zero. On a cold start, you pay it in full.
Before optimising, measure. Add the AWS Lambda Powertools Java dependency for structured metrics:
public class ClaimHandler implements RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {
private final ClaimService claimService;
public ClaimHandler() {
// This constructor runs at cold start
long start = System.currentTimeMillis();
this.claimService = SpringLambda.getBean(ClaimService.class);
long initTime = System.currentTimeMillis() - start;
log.info("Cold start init time: {}ms", initTime);
}
@Override
public APIGatewayProxyResponseEvent handleRequest(
APIGatewayProxyRequestEvent event, Context context) {
// Warm invocations arrive here directly
return claimService.handle(event);
}
}
Log and track initTime. Track in CloudWatch and alert when p99 cold start time exceeds your threshold.
AWS Lambda SnapStart (available for Java 11+) snapshots the Lambda execution environment after initialisation and restores from the snapshot on subsequent cold starts. For Spring Boot applications, this means you pay the initialisation cost once — future cold starts restore from the snapshot in ~200ms.
Enable it in your template.yml:
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Resources:
ClaimFunction:
Type: AWS::Serverless::Function
Properties:
Runtime: java21
SnapStart:
ApplyOn: PublishedVersions
Handler: com.example.ClaimHandler
CodeUri: target/claim-service.jar
MemorySize: 512
Timeout: 30
SnapStart requires that your initialisation code is idempotent — the snapshot is taken once and restored many times. Watch for:
Implement CRaC hooks to handle pre-checkpoint and post-restore lifecycle:
import org.crac.Context;
import org.crac.Core;
import org.crac.Resource;
@Component
public class SnapStartLifecycle implements Resource {
private final DataSource dataSource;
@PostConstruct
public void register() {
Core.getGlobalContext().register(this);
}
@Override
public void beforeCheckpoint(Context<? extends Resource> ctx) throws Exception {
// Close connections before snapshot is taken
dataSource.getConnection().close();
}
@Override
public void afterRestore(Context<? extends Resource> ctx) throws Exception {
// Re-establish connections after restore
dataSource.getConnection(); // warm up connection pool
}
}
GraalVM compiles your Spring Boot application to a native executable — no JVM startup, AOT-compiled code. Cold starts of 50–200ms are achievable:
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<configuration>
<imageName>claim-function</imageName>
<buildArgs>
<buildArg>--no-fallback</buildArg>
<buildArg>--enable-url-protocols=https</buildArg>
</buildArgs>
</configuration>
</plugin>
Spring Boot 3.x has first-class GraalVM support via spring-boot-starter + AOT processing. Most Spring features work — but dynamic features (reflection, proxies, runtime classpath scanning) require explicit hint configuration:
@RegisterReflectionForBinding({ClaimEvent.class, ClaimResponse.class})
@Configuration
public class LambdaConfig {
// beans for Lambda handler
}
Native image compilation is slow (5–10 minutes on a typical build machine) and imposes constraints on dynamic features. For Lambda functions with stable, well-defined inputs and outputs, it’s the best option for cold start performance.
Without native image, JVM flags reduce cold start time:
# In Lambda function environment variables
JAVA_TOOL_OPTIONS="-XX:+TieredCompilation -XX:TieredStopAtLevel=1"
TieredStopAtLevel=1 disables C2 (optimising) compilation and uses only the fast interpreter + C1 (baseline) compiler. Startup is faster; peak throughput is lower. For Lambda where invocations are short-lived, this is generally the right trade-off.
Lambda CPU allocation scales with memory. A 512MB Lambda gets half a vCPU; 1024MB gets a full vCPU; 1769MB gets two vCPUs. For a Spring Boot application:
Extra memory reduces cold start time faster than the marginal cost increase. For a Lambda behind an API Gateway, 1024–2048MB is often optimal.
Lambda is not appropriate for:
Lambda is ideal for: event-driven processing triggered by S3, SQS, SNS, EventBridge; infrequent API endpoints; scheduled batch jobs; and data transformation functions.
BeanDefinitionCustomizerParameterizedCondition and @Bean-based configuration start faster than classpath component scanning.If you’re looking for a Java contractor who knows this space inside out, get in touch.