AWS Lambda Cold Start Optimization Using Provisioned Concurrency And SnapStart
AWS Lambda Cold Start Optimization Using Provisioned Concurrency and SnapStart
Cold starts are the silent SLO killer in serverless architectures. You've built a beautiful Lambda function, deployed it, and everything looks great in your load tests — until that first user hits your API after a period of inactivity and experiences a 3-second latency spike. Your p99 charts cry, your error budget bleeds, and your on-call phone buzzes.
Let's fix that.
Why Cold Starts Actually Matter (With Numbers)
Before we dive into solutions, let's be precise about the problem. A Lambda cold start happens when AWS needs to provision a new execution environment for your function. This involves:
- Allocating compute infrastructure
- Downloading your deployment package
- Starting the language runtime
- Running your initialization code (anything outside the handler)
Cold start durations vary significantly by runtime:
| Runtime | Typical Cold Start | With Large Dependencies |
|---|---|---|
| Python 3.12 | 200–500ms | 500ms–2s |
| Node.js 20 | 150–400ms | 400ms–1.5s |
| Java 21 | 1–4s | 3–8s |
| .NET 8 | 500ms–2s | 1–4s |
Java is the notorious offender here. JVM initialization is expensive, and if you're using frameworks like Spring Boot, you can easily hit 8+ second cold starts. That's not just bad UX — that's an API Gateway timeout waiting to happen.
Strategy 1: Write Leaner Initialization Code
This is free and you should do it first. Stop putting expensive operations outside your handler when they don't need to be there.
import boto3
import json
# BAD: This runs on every cold start, even if unused
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('my-table')
secrets_client = boto3.client('secretsmanager')
secret = secrets_client.get_secret_value(SecretId='my-secret') # Network call!
def handler(event, context):
return table.get_item(Key={'id': event['id']})
import boto3
import json
# GOOD: Lazy initialization with caching
_dynamodb_table = None
_secret_cache = None
def get_table():
global _dynamodb_table
if _dynamodb_table is None:
dynamodb = boto3.resource('dynamodb')
_dynamodb_table = dynamodb.Table('my-table')
return _dynamodb_table
def get_secret():
global _secret_cache
if _secret_cache is None:
client = boto3.client('secretsmanager')
_secret_cache = client.get_secret_value(SecretId='my-secret')
return _secret_cache
def handler(event, context):
table = get_table()
return table.get_item(Key={'id': event['id']})
The second pattern still benefits from warm execution environment caching, but doesn't penalize every cold start with unnecessary network calls if the code path doesn't require them.
Strategy 2: Provisioned Concurrency
Provisioned Concurrency is AWS's solution for keeping Lambda execution environments pre-initialized and warm. You're paying for idle capacity, but you're buying a latency guarantee.
When to use it: High-traffic APIs where cold starts are measurable, customer-facing workflows where p99 matters, or any function where JVM/CLR startup is a real problem.
Setting Up Provisioned Concurrency via Terraform
resource "aws_lambda_function" "api_handler" {
function_name = "api-handler"
role = aws_iam_role.lambda_role.arn
handler = "handler.main"
runtime = "python3.12"
filename = "lambda.zip"
memory_size = 1024
timeout = 30
publish = true # Required for Provisioned Concurrency
}
resource "aws_lambda_alias" "live" {
name = "live"
function_name = aws_lambda_function.api_handler.function_name
function_version = aws_lambda_function.api_handler.version
}
resource "aws_lambda_provisioned_concurrency_config" "api_handler" {
function_name = aws_lambda_function.api_handler.function_name
qualifier = aws_lambda_alias.live.name
provisioned_concurrent_executions = 10
}
Critical detail: Provisioned Concurrency requires a published version or alias. It does NOT work on $LATEST. This trips up a lot of people.
Auto-Scaling Provisioned Concurrency
Flat provisioned concurrency is wasteful. Use Application Auto Scaling to match your traffic patterns:
resource "aws_appautoscaling_target" "lambda_target" {
max_capacity = 50
min_capacity = 5
resource_id = "function:${aws_lambda_function.api_handler.function_name}:${aws_lambda_alias.live.name}"
scalable_dimension = "lambda:function:ProvisionedConcurrency"
service_namespace = "lambda"
}
resource "aws_appautoscaling_policy" "lambda_scaling_policy" {
name = "lambda-provisioned-concurrency-scaling"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.lambda_target.resource_id
scalable_dimension = aws_appautoscaling_target.lambda_target.scalable_dimension
service_namespace = aws_appautoscaling_target.lambda_target.service_namespace
target_tracking_scaling_policy_configuration {
target_value = 0.7 # Scale when 70% of provisioned capacity is utilized
predefined_metric_specification {
predefined_metric_type = "LambdaProvisionedConcurrencyUtilization"
}
scale_in_cooldown = 300
scale_out_cooldown = 30 # Scale out fast, scale in slow
}
}
The asymmetric cooldown is intentional. You want to scale out quickly when traffic spikes but give things time to stabilize before scaling back in.
Strategy 3: Lambda SnapStart (Java's Game Changer)
If you're running Java on Lambda and not using SnapStart, you're leaving a massive performance gain on the table. SnapStart takes a snapshot of the initialized execution environment after your init phase completes and restores from that snapshot on subsequent invocations.
The result? Java cold starts that drop from 3-8 seconds to under 1 second.
Enabling SnapStart
resource "aws_lambda_function" "java_api" {
function_name = "java-api-handler"
role = aws_iam_role.lambda_role.arn
handler = "com.example.Handler::handleRequest"
runtime = "java21"
filename = "function.jar"
memory_size = 1024
timeout = 30
snap_start {
apply_on = "PublishedVersions"
}
publish = true
}
Handling SnapStart Lifecycle Hooks
Here's where SnapStart gets nuanced. When Lambda restores from a snapshot, your code needs to be aware that it's resuming from a frozen state. Connections, random number generators, and time-sensitive state can all be stale.
AWS provides lifecycle hooks via the CRaC (Coordinated Restore at Checkpoint) interface:
import org.crac.Context;
import org.crac.Core;
import org.crac.Resource;
import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;
public class Handler implements RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent>, Resource {
private DynamoDbClient dynamoClient;
private static final String TABLE_NAME = System.getenv("TABLE_NAME");
public Handler() {
// This runs during snapshot creation
Core.getGlobalContext().register(this);
this.dynamoClient = buildDynamoClient();
}
@Override
public void beforeCheckpoint(Context<? extends Resource> context) throws Exception {
// Called before snapshot is taken
// Close any connections that shouldn't be serialized
System.out.println("Preparing for checkpoint...");
if (dynamoClient != null) {
dynamoClient.close();
dynamoClient = null;
}
}
@Override
public void afterRestore(Context<? extends Resource> context) throws Exception {
// Called after restore from snapshot
// Re-establish connections, refresh credentials, reseed RNGs
System.out.println("Restored from snapshot, reinitializing...");
this.dynamoClient = buildDynamoClient();
}
private DynamoDbClient buildDynamoClient() {
return DynamoDbClient.builder()
.region(Region.of(System.getenv("AWS_REGION")))
.build();
}
@Override
public APIGatewayProxyResponseEvent handleRequest(
APIGatewayProxyRequestEvent event,
com.amazonaws.services.lambda.runtime.Context context) {
GetItemResponse response = dynamoClient.getItem(
GetItemRequest.builder()
.tableName(TABLE_NAME)
.key(Map.of("id", AttributeValue.builder().s(event.getPathParameters().get("id")).build()))
.build()
);
return new APIGatewayProxyResponseEvent()
.withStatusCode(200)
.withBody(response.item().toString());
}
}
The big gotcha with SnapStart: Anything that depends on entropy or randomness (UUID generation, cryptographic operations, connection pools) needs to be refreshed in afterRestore. A UUID generated before the checkpoint will be the same UUID on every restore. That's a disaster for any ID-generating code.
Measuring the Impact (Because It Doesn't Exist If You Don't Measure It)
You've made changes. Now prove they worked. CloudWatch has the metrics you need.
import boto3
from datetime import datetime, timedelta
def get_cold_start_metrics(function_name: str, hours: int = 24):
"""
Pull Lambda cold start data from CloudWatch.
Note: Lambda doesn't emit a native 'ColdStart' metric.
You need to emit it yourself or use CloudWatch Lambda Insights.
"""
cloudwatch = boto3.client('cloudwatch')
end_time = datetime.utcnow()
start_time = end_time - timedelta(hours=hours)
# Init Duration only appears in cold start invocations
response = cloudwatch.get_metric_statistics(
Namespace='AWS/Lambda',
MetricName='InitDuration',
Dimensions=[
{'Name': 'FunctionName', 'Value': function_name}
],
StartTime=start_time,
EndTime=end_time,
Period=3600,
Statistics=['Average', 'Maximum', 'SampleCount']
)
return response['Datapoints']
Better yet, emit your own cold start signal:
import os
import boto3
import time
_cold_start = True
_cloudwatch = boto3.client('cloudwatch')
def emit_cold_start_metric(function_name: str, duration_ms: float):
_cloudwatch.put_metric_data(
Namespace='Application/Lambda',
MetricData=[
{
'MetricName': 'ColdStart',
'Dimensions': [
{'Name': 'FunctionName', 'Value': function_name},
{'Name': 'Environment', 'Value': os.environ.get('ENVIRONMENT', 'unknown')}
],
'Value': 1,
'Unit': 'Count'
},
{
'MetricName': 'ColdStartDuration',
'Dimensions': [
{'Name': 'FunctionName', 'Value': function_name}
],
'Value': duration_ms,
'Unit': 'Milliseconds'
}
]
)
def handler(event, context):
global _cold_start
if _cold_start:
# Lambda provides this in the context, but it's ms since epoch
# InitDuration isn't directly accessible, but you can approximate
emit_cold_start_metric(context.function_name, context.memory_limit_in_mb)
_cold_start = False
# Your actual handler logic
return {'statusCode': 200, 'body': 'OK'}
For production, I'd strongly recommend enabling Lambda Insights — it gives you detailed cold start visibility without the custom metric overhead.
Cost vs. Latency Tradeoffs
Let's be honest about the economics:
Provisioned Concurrency costs you for idle time. At 10 provisioned concurrent executions for a function with 1GB memory running 24/7, you're looking at roughly $150/month just for the provisioned capacity, before invocation costs. For a low-traffic API, that's expensive. For a $50K/month revenue-generating checkout flow, it's noise.
SnapStart is effectively free — you pay only for the actual execution time, not pre-initialization. The tradeoff is complexity in managing lifecycle hooks. For Java workloads, it's almost always the right first choice.
My opinionated decision tree:
- Java function with cold start > 1s → Enable SnapStart first, always
- Customer-facing API with SLO on p99 latency → Provisioned Concurrency with auto-scaling
- Internal async processing, cold starts acceptable → Neither, save the money
- Traffic with predictable daily patterns → Scheduled scaling of Provisioned Concurrency
Quick Wins Checklist
Before spending money on Provisioned Concurrency, run through this list:
- Move expensive initialization to lazy loading patterns
- Reduce deployment package size (smaller packages = faster downloads)
- Increase memory allocation — CPU scales with memory in Lambda
- Use Lambda layers to share common dependencies
- Enable SnapStart if you're on Java 21+
- Consider arm64/Graviton2 — often 10-20% faster initialization and cheaper
# Quick check on your function's package size
aws lambda get-function --function-name my-function \
--query 'Configuration.CodeSize' \
--output text | \
awk '{printf "Package size: %.2f MB\n", $1/1024/1024}'
Anything over 50MB deserves a hard look at what's in there.
The Bottom Line
Cold starts are a solvable problem, but the solution depends on your workload. Don't reach for Provisioned Concurrency as the first solution — it's a cost lever, not a free optimization. Start with code hygiene, enable SnapStart for Java, and measure everything before and after.
If your SLO says p99 must be under 500ms and you're seeing cold starts at 2 seconds, you have a budget to justify Provisioned Concurrency. If you're seeing cold starts at 300ms on a function that runs 10 times a day, you don't.
Observability first. Optimization second. Always know what you're solving before you throw money at it.
Was this article helpful?
SRE & Observability Engineer
If it's not measured, it doesn't exist. SLO-driven, metrics-obsessed, and the person who gets paged at 3 AM so you don't have to. Observability isn't optional.
Related Articles
AWS IAM Least Privilege: Policies That Won't Lock You Out
Build AWS IAM policies using least privilege without locking yourself out — practical patterns for roles, service accounts, and permission boundaries.
AWS ECS Fargate: Serverless Container Deployment Without Managing Nodes
Deploy containers on AWS ECS Fargate — no EC2 instances to manage. Covers task definitions, services, load balancers, environment variables, secrets from SSM/Secrets Manager, auto-scaling, and CI/CD integration.
AWS EKS: Production Kubernetes Cluster Setup from Scratch
Step-by-step guide to launching a production-ready EKS cluster on AWS — node groups, IAM roles, VPC configuration, managed add-ons, kubeconfig setup, and cost optimization. Both eksctl and Terraform approaches covered.
Fix AWS EC2 Instance 'Status Check Failed' Errors
Diagnose and recover AWS EC2 instances with failed system or instance status checks using stop/start, volume rescue, and auto-recovery techniques.
Fix AWS S3 'Access Denied' Errors
Systematically troubleshoot and fix AWS S3 Access Denied errors caused by IAM policies, bucket policies, ACLs, and encryption settings.
Cloud Landing Zone Architecture: Account Structure Done Right
How to design an AWS Landing Zone with Organizations, SCPs, and account vending that scales from 5 to 500 accounts without becoming a mess.
More in AWS
View all →AWS CLI: Cheat Sheet
AWS CLI cheat sheet with copy-paste commands for EC2, S3, IAM, Lambda, ECS, CloudFormation, SSM, and Secrets Manager operations.
AWS Core Services: The DevOps Engineer's Essential Guide
Navigate the essential AWS building blocks — EC2, S3, VPC, IAM, RDS, Lambda, and EKS explained for DevOps engineers with practical examples.