DevOpsil
AWS
82%

AWS Lambda Cold Start Optimization Using Provisioned Concurrency And SnapStart

Riku TanakaRiku Tanaka9 min read

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:

  1. Allocating compute infrastructure
  2. Downloading your deployment package
  3. Starting the language runtime
  4. Running your initialization code (anything outside the handler)

Cold start durations vary significantly by runtime:

RuntimeTypical Cold StartWith Large Dependencies
Python 3.12200–500ms500ms–2s
Node.js 20150–400ms400ms–1.5s
Java 211–4s3–8s
.NET 8500ms–2s1–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.

Share:

Was this article helpful?

Riku Tanaka
Riku Tanaka

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

AWSTutorialBeginnerNeeds Review

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.

Aareez Asif·
6 min read
AWSQuick RefBeginnerNeeds Review

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.

Sarah Chen·
3 min read

More in AWS

View all →
AWSQuick RefBeginnerNeeds Review

AWS CLI: Cheat Sheet

AWS CLI cheat sheet with copy-paste commands for EC2, S3, IAM, Lambda, ECS, CloudFormation, SSM, and Secrets Manager operations.

Dev Patel·
3 min read

Discussion