AWS Lambda Cold Start Optimization: Runtime Selection and Provisioned Concurrency

Where Cold Starts Actually Come From

Every article about Lambda cold starts mentions the problem, but few dig into the mechanics. When a new execution environment spins up, AWS has to allocate compute, download your deployment package, start the runtime, and run your initialization code. That entire chain takes time — anywhere from 100ms for a tiny Python function to 10+ seconds for a JVM-based function with heavy dependencies.

The init phase has two parts that you control: the deployment package size and the initialization code. A 50MB zip with unused dependencies takes longer to download than a 5MB trimmed package. And code that runs at import time — database connection pools, SDK client initialization, model loading — adds to cold start duration regardless of the runtime.

Runtime Performance Benchmarks

I've measured cold starts across runtimes in us-east-1 with 256MB memory allocation on functions with minimal dependencies. Rough numbers:

  • Python 3.12: 180-250ms cold start
  • Node.js 20.x: 150-220ms cold start
  • Go (provided.al2023): 80-120ms cold start — consistently the fastest
  • Java 21 (with SnapStart): 200-400ms — a dramatic improvement from Java's raw 2-8 second cold starts
  • .NET 8 with AOT: 250-350ms

Memory allocation affects cold start time. It's counterintuitive, but bumping memory from 128MB to 512MB often reduces cold start duration because AWS allocates proportionally more CPU. I've seen Python functions drop from 800ms to 200ms cold starts just by increasing memory from 128MB to 1024MB.

Provisioned Concurrency: The Direct Fix

Provisioned concurrency keeps N execution environments warm at all times. There's no cold start for requests served by provisioned instances — they're already initialized and waiting.

# CLI command to set provisioned concurrency
aws lambda put-provisioned-concurrency-config   --function-name my-api-handler   --qualifier prod   --provisioned-concurrent-executions 50

The cost model is straightforward. You pay for provisioned concurrency whether or not it's used — $0.0000041667 per GB-second of provisioned concurrency in us-east-1. For a 512MB function, that's about $0.054/hour for 50 provisioned instances. That translates to roughly $39/month for 50 warm instances of a 512MB function.

Compare that to the cold start impact: if your P99 latency requirement is 200ms and cold starts add 1.5 seconds, provisioned concurrency is the only way to guarantee consistent performance.

Auto Scaling Provisioned Concurrency

Static provisioned concurrency wastes money during off-peak hours. Application Auto Scaling can adjust provisioned concurrency based on a schedule or utilization target.

# Schedule-based scaling for business hours
aws application-autoscaling register-scalable-target   --service-namespace lambda   --resource-id function:my-api-handler:prod   --scalable-dimension lambda:function:ProvisionedConcurrency   --min-capacity 10   --max-capacity 100

# Target tracking policy - maintain 70% utilization
aws application-autoscaling put-scaling-policy   --service-namespace lambda   --resource-id function:my-api-handler:prod   --scalable-dimension lambda:function:ProvisionedConcurrency   --policy-name target-tracking   --policy-type TargetTrackingScaling   --target-tracking-scaling-policy-configuration '{
    "TargetValue": 0.7,
    "PredefinedMetricSpecification": {
      "PredefinedMetricType": "LambdaProvisionedConcurrencyUtilization"
    }
  }'

I've found that a 0.7 target utilization gives a good buffer — enough headroom for traffic spikes without over-provisioning.

SnapStart for Java Functions

If you're running Java, SnapStart is worth serious consideration before reaching for provisioned concurrency. AWS takes a snapshot of the initialized execution environment after your init code runs, then restores from that snapshot instead of cold-starting. This drops Java cold starts from 5-8 seconds down to 200-400ms.

The catch: your init code needs to be snapshot-safe. File handles, network connections, and random number generators created during init won't survive the snapshot/restore cycle. Use runtime hooks to reinitialize these resources.

Package Optimization Tricks

Beyond runtime selection and provisioned concurrency, slimming down your deployment package helps every cold start — provisioned or not.

For Python, use Lambda Layers for large dependencies and strip unnecessary files. The boto3 library is already included in the Lambda runtime, so don't bundle it. For Node.js, tree-shake your dependencies and consider esbuild for bundling. For Java, use ProGuard or the maven shade plugin to strip unused classes.

Container images have their own optimization path: use the AWS-provided base images (which are cached on Lambda's infrastructure), minimize layers, and put infrequently-changing dependencies in early layers to maximize cache hits.

Lambda Extensions and Their Cold Start Impact

Lambda extensions run alongside your function code and share the execution environment. Internal extensions (like APM agents) run in the same process. External extensions (like log forwarders) run as separate processes. Both add to cold start time because they initialize during the init phase.

I've measured the impact: a typical observability extension adds 200-500ms to cold starts. Two extensions stacked together can easily add a full second. If you're optimizing cold starts aggressively, audit your extensions and ask whether each one justifies its cold start tax.

Connection Pooling Across Invocations

Database connections are expensive to establish, and creating a new one per invocation kills performance. Initialize your connection pool outside the handler function so it persists across warm invocations.

# Python: connection initialized at module level
import psycopg2
from psycopg2 import pool

connection_pool = psycopg2.pool.SimpleConnectionPool(
    minconn=1, maxconn=5,
    host=os.environ['DB_HOST'],
    dbname=os.environ['DB_NAME'],
    user=os.environ['DB_USER'],
    password=os.environ['DB_PASSWORD']
)

def handler(event, context):
    conn = connection_pool.getconn()
    try:
        with conn.cursor() as cur:
            cur.execute("SELECT * FROM orders WHERE id = %s",
                       (event['order_id'],))
            result = cur.fetchone()
        return {'statusCode': 200, 'body': json.dumps(result)}
    finally:
        connection_pool.putconn(conn)

For RDS connections specifically, RDS Proxy eliminates the connection pooling problem entirely. The proxy maintains a warm pool of database connections that Lambda functions share.

ARM64 vs x86 for Cold Start Performance

Graviton2 (ARM64) Lambda functions show roughly 10-15% faster cold starts compared to x86 in my benchmarks, and they're 20% cheaper. The combination makes ARM64 the default choice for new functions unless you have x86-specific native dependencies.

For Node.js and Go functions, the switch to ARM64 is straightforward. Just change the architecture setting and redeploy. The performance improvement is measurable and the cost savings compound over millions of invocations per month.

Measuring Cold Start Frequency in Production

Before optimizing, measure how often cold starts actually happen. Add a flag in your function that detects cold starts and log it. Then calculate the percentage of invocations that experience cold starts. If 0.1% of your invocations are cold starts and your P99 requirement is met, the optimization effort isn't worth it.

Tuning Memory for CPU-Bound Functions

Lambda allocates CPU proportionally to memory. At 1769 MB, you get one full vCPU. At 3538 MB, you get two. This means a function that's CPU-bound — parsing JSON, running computation, compressing data — benefits from more memory even if it doesn't use the extra RAM.

The right way to tune this: use AWS Lambda Power Tuning, an open-source tool that runs your function at different memory settings and plots the cost-performance curve. Often you'll find a sweet spot where doubling the memory halves the execution time, resulting in the same cost but better latency. Beyond that sweet spot, additional memory gives diminishing returns.

I ran Power Tuning on a Python function that processes images. At 256 MB, it took 8 seconds. At 1024 MB, it took 2.1 seconds. At 2048 MB, it took 1.8 seconds. The jump from 256 to 1024 was dramatic; 1024 to 2048 barely mattered. The 1024 MB config was actually cheaper per invocation because the time reduction more than offset the memory price increase.

For cold starts specifically, the memory-to-cold-start relationship isn't linear. Going from 128 MB to 512 MB typically cuts cold start by 40-60%. Going from 512 MB to 2048 MB cuts another 10-15%. The biggest bang for your buck is in that first jump from the minimum memory allocation. If you're on 128 MB and complaining about cold starts, try 512 MB first before reaching for provisioned concurrency.

Layer Strategy for Shared Dependencies

Lambda Layers let you package dependencies separately from your function code. When your function's code changes but dependencies don't, only the function code re-downloads during cold start — the layer is already cached. This is a meaningful cold start optimization for functions with heavy dependency trees.

But there's a practical limit. A function can use up to 5 layers, and the total unzipped deployment size (function + all layers) can't exceed 250 MB. Structure your layers by change frequency: stable dependencies like boto3 extensions or numpy in one layer, and your own shared utility code in another layer that changes more often. This maximizes cache hits on the stable layer.