Spring Boot on AWS Lambda: Serverless Java with SnapStart
Running Spring Boot on AWS Lambda combines the familiar Spring programming model with the cost and scaling benefits of serverless. The challenge has always been cold starts: the time it takes for the JVM to initialise and Spring to wire its context before the first request. AWS SnapStart (2022) addresses this by taking a Firecracker snapshot of the initialised JVM, reducing cold starts to under 1 second. This article covers two deployment approaches: the Lambda Web Adapter (treats Lambda as an HTTP server) and the native function handler (for lightweight operations), plus SAM CLI local testing and tuning strategies.
Contents
Deployment Approaches
There are two practical ways to run Spring Boot code on AWS Lambda. Choosing the right one depends on whether you are migrating an existing HTTP API or building a purpose-built serverless function.
| Approach | How it works | Best for |
|---|---|---|
| Lambda Web Adapter | A thin Lambda extension that proxies API Gateway events to an HTTP server running on localhost:8080 inside the same execution environment | Migrating existing Spring Boot REST APIs; familiar MVC/WebFlux code with no changes |
| Function Handler | Implement RequestHandler<I,O>Function<I,O> | Lightweight event processors: SQS, SNS, EventBridge triggers with simple logic |
Lambda Web Adapter: Full Spring Boot
The AWS Lambda Web Adapter (open source, maintained by AWS) wraps any HTTP server process. It starts your Spring Boot app as a subprocess listening on port 8080, then translates Lambda invocation events (API Gateway v1/v2, ALB) into HTTP requests and sends responses back. From Spring's perspective it is a normal HTTP server startup.
<!-- pom.xml — standard Spring Boot Web dependency; no Lambda SDK needed -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
// Standard Spring Boot application — zero Lambda-specific code
@SpringBootApplication
public class ProductApi {
public static void main(String[] args) {
SpringApplication.run(ProductApi.class, args);
}
}
@RestController
@RequestMapping("/products")
public class ProductController {
@GetMapping("/{id}")
public ResponseEntity<Product> getProduct(@PathVariable Long id) {
return ResponseEntity.ok(productService.findById(id));
}
@PostMapping
public ResponseEntity<Product> create(@RequestBody @Valid Product product) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(productService.save(product));
}
}
# Dockerfile — include Lambda Web Adapter extension layer
FROM public.ecr.aws/lambda/java:21
# Copy the Lambda Web Adapter binary from the official layer
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.8.4 \
/lambda-adapter /opt/extensions/lambda-adapter
# Tell the adapter which port Spring Boot listens on (default 8080)
ENV PORT=8080
ENV AWS_LAMBDA_EXEC_WRAPPER=/opt/bootstrap
COPY target/product-api-1.0.jar ${LAMBDA_TASK_ROOT}/app.jar
CMD ["software.amazon.awssdk.lambda.LambdaBootstrap"]
Native Function Handler
For event-driven workloads (SQS, SNS, S3 triggers) where the overhead of a full MVC stack is unnecessary, implement a slim Spring-managed RequestHandlerFunction
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-function-adapter-aws</artifactId>
</dependency>
@SpringBootApplication
public class OrderProcessor {
public static void main(String[] args) {
SpringApplication.run(OrderProcessor.class, args);
}
// Spring Cloud Function — the Lambda handler routes here by function name
@Bean
public Function<Order, ProcessingResult> processOrder(OrderService svc) {
return order -> {
svc.validate(order);
svc.save(order);
svc.publishEvent(order);
return new ProcessingResult("OK", order.getId());
};
}
}
# application.yml
spring:
cloud:
function:
definition: processOrder # tells the AWS adapter which Function bean to invoke
# Lambda configuration
Handler: org.springframework.cloud.function.adapter.aws.FunctionInvoker::handleRequest
Runtime: java21
Memory: 512 MB
Timeout: 30 s
AWS SnapStart
SnapStart works by taking a Firecracker MicroVM snapshot of the Lambda execution environment after the Init
- Available for Java 11+ managed runtimes and container images with Lambda Web Adapter
- Enabled per Lambda function via the SnapStart configuration setting
- Each new published version gets a fresh snapshot: snapshots are not shared between versions
- Free: no additional cost beyond normal Lambda pricing
# SAM template.yaml — enable SnapStart on a function
Resources:
ProductApi:
Type: AWS::Serverless::Function
Properties:
Handler: org.springframework.cloud.function.adapter.aws.FunctionInvoker::handleRequest
Runtime: java21
MemorySize: 1024
SnapStart:
ApplyOn: PublishedVersions # snapshot taken on publish, not $LATEST
AutoPublishAlias: live # always route alias to latest published version
Environment:
Variables:
SPRING_PROFILES_ACTIVE: prod
CRaC@PreSnapshot@AfterRestoreSnapshotHookSAM CLI: Local Testing & Deployment
AWS SAM (Serverless Application Model) provides a CLI that can invoke Lambda functions locally using Docker, emulate API Gateway, and deploy the full stack via CloudFormation. It is the standard tool for the Lambda development loop.
# Install SAM CLI (macOS)
brew tap aws/tap
brew install aws-sam-cli
# Build the project (compiles, packages, copies to .aws-sam/build/)
sam build
# Start local API Gateway (emulates HTTP triggers on localhost:3000)
sam local start-api
# Invoke a single function with a test event
sam local invoke ProductApi --event events/get-product.json
# Tail logs from a deployed function
sam logs -n ProductApi --stack-name my-stack --tail
# Deploy with guided prompts (first time)
sam deploy --guided
# Deploy using existing samconfig.toml (subsequent deployments)
sam deploy
// events/get-product.json — sample API Gateway v2 event
{
"version": "2.0",
"routeKey": "GET /products/{id}",
"rawPath": "/products/42",
"pathParameters": { "id": "42" },
"requestContext": {
"http": { "method": "GET", "path": "/products/42" },
"stage": "$default"
},
"isBase64Encoded": false
}
Cold Start Optimisation
Even with SnapStart, understanding cold start causes helps you build faster functions. Cold start time = JVM launch + class loading + Spring context init + first-request logic. Each phase can be optimised independently.
| Technique | Impact | Notes |
|---|---|---|
| SnapStart | ⬇ 80–95% cold start | Enable via ApplyOn: PublishedVersions |
| Increase memory | ⬇ 20–50% init time | More memory = more CPU. 512–1024 MB is the sweet spot for Spring Boot |
| spring.main.lazy-initialization=true | ⬇ 30–60% context init | Beans initialise on first use; trades cold start for slightly slower first request |
| Reduce classpath / dependencies | ⬇ 10–30% class loading | Remove unused Spring Boot starters; use spring-boot-starter-web not spring-boot-starter |
| GraalVM Native Image | ⬇ 95%+ cold start, ⬇ memory | Sub-100 ms starts; requires native build pipeline; no SnapStart needed |
| Provisioned Concurrency | Eliminates cold starts entirely | Pre-warms N instances; costs money even at zero traffic |
# application-lambda.yml — Lambda-optimised Spring Boot settings
spring:
main:
lazy-initialization: true
banner-mode: off
jpa:
open-in-view: false # disable OSIV — saves a database connection per request
datasource:
hikari:
minimum-idle: 1 # cold Lambda doesn't need pool depth
maximum-pool-size: 5
connection-timeout: 3000
Configuration & Environment Variables
Lambda functions read configuration from environment variables. Spring Boot's externalized configuration picks these up automatically: no code changes needed. For secrets, always use AWS Systems Manager Parameter Store or Secrets Manager rather than plain environment variable values.
// application.yml references environment variables
// Spring Boot reads them automatically via @Value or @ConfigurationProperties
spring:
datasource:
url: ${DB_URL} # set in Lambda environment
username: ${DB_USERNAME}
password: ${DB_PASSWORD} # or: resolved from Secrets Manager via AWS extension
cloud:
aws:
region:
static: ${AWS_DEFAULT_REGION:us-east-1}
# Use AWS AppConfig or Parameter Store extension for dynamic config
# Extension reads SSM params and exposes them as localhost HTTP at startup
# SAM template — inject config from SSM Parameter Store
Environment:
Variables:
DB_URL: !Sub "{{resolve:ssm:/myapp/prod/db-url}}"
# Or use the AWS Parameters and Secrets Lambda Extension (layer)
# for runtime fetching without SDK calls in application code
Observability: Logs, Tracing & Metrics
Lambda automatically sends stdout/stderr to CloudWatch Logs. For structured logs and distributed tracing, add the AWS X-Ray SDK and configure Micrometer for CloudWatch metrics. Spring Boot 3's Micrometer integration makes this straightforward.
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-xray-recorder-sdk-spring</artifactId>
<version>2.15.3</version>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-cloudwatch2</artifactId>
</dependency>
// Structured JSON logging — Lambda CloudWatch Logs Insights can query fields
@Configuration
public class LoggingConfig {
@PostConstruct
void configure() {
// Logback JSON encoder (net.logstash.logback:logstash-logback-encoder)
// Outputs: {"timestamp":"...","level":"INFO","message":"...","traceId":"..."}
}
}
// X-Ray active tracing annotation
@XRayEnabled
@Service
public class ProductService {
public Product findById(Long id) {
// X-Ray creates a subsegment automatically for this method
return repository.findById(id).orElseThrow();
}
}
# Enable X-Ray active tracing in SAM template
Properties:
Tracing: Active
Policies:
- AWSXRayDaemonWriteAccess